diff --git a/.circleci/config.yml b/.circleci/config.yml index f13e9bf66f1..b0a705966a2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,6 +5,16 @@ orbs: win: circleci/windows@5.0 # Add Windows orb commands: + skip_if_unrelated_changes: + parameters: + category: + type: enum + enum: ["backend", "client"] + default: "backend" + steps: + - run: + name: "Skip job when no << parameters.category >>-relevant files changed" + command: bash .circleci/scripts/path_filter.sh << parameters.category >> setup_google_dns: steps: - run: @@ -282,6 +292,7 @@ jobs: parallelism: 4 steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - restore_cache: keys: @@ -354,6 +365,7 @@ jobs: parallelism: 4 steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - restore_cache: keys: @@ -427,6 +439,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - restore_cache: keys: @@ -480,6 +493,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -545,6 +559,7 @@ jobs: DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test" steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -584,6 +599,7 @@ jobs: DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test" steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -624,6 +640,7 @@ jobs: DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test" steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -656,6 +673,7 @@ jobs: FAKE_OPENAI_API_BASE: http://127.0.0.1:8190 steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - restore_cache: @@ -705,6 +723,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - restore_cache: @@ -755,6 +774,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -787,6 +807,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - restore_cache: @@ -832,6 +853,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -877,6 +899,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -918,6 +941,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -963,6 +987,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1004,9 +1029,12 @@ jobs: - *python312_image working_directory: ~/project resource_class: large + environment: + REQUEST_TIMEOUT: "180" steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - restore_cache: @@ -1032,7 +1060,8 @@ jobs: -v -x \ --junitxml=test-results/junit.xml \ --durations=5 \ - -n 8" + -n 8 \ + --reruns 1 --only-rerun Timeout" no_output_timeout: 15m # Store test results @@ -1045,6 +1074,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1089,6 +1119,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1132,6 +1163,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1163,6 +1195,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1205,6 +1238,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1248,6 +1282,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1291,6 +1326,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1321,6 +1357,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1366,6 +1403,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1407,6 +1445,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - restore_cache: keys: @@ -1459,6 +1498,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1482,6 +1522,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1507,6 +1548,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1531,6 +1573,7 @@ jobs: steps: - checkout + - skip_if_unrelated_changes - attach_workspace: at: ~/project - setup_google_dns @@ -1570,14 +1613,14 @@ jobs: - run: name: Run helm lint command: | - helm lint ./deploy/charts/litellm-helm + helm lint ./helm/litellm-helm # Run helm tests - run: name: Run helm tests command: | IMAGE_TAG=${CIRCLE_SHA1:-ci} - helm install litellm ./deploy/charts/litellm-helm -f ./deploy/charts/litellm-helm/ci/test-values.yaml \ + helm install litellm ./helm/litellm-helm -f ./helm/litellm-helm/ci/test-values.yaml \ --set image.repository=litellm-ci \ --set image.tag=${IMAGE_TAG} \ --set image.pullPolicy=Never @@ -1606,6 +1649,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1698,6 +1742,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - attach_workspace: at: ~/project - setup_google_dns @@ -1746,13 +1791,13 @@ jobs: -e LANGFUSE_PROJECT1_SECRET=$LANGFUSE_PROJECT1_SECRET \ -e LANGFUSE_PROJECT2_SECRET=$LANGFUSE_PROJECT2_SECRET \ -e RECORDER_OPENAI_BASE_URL=http://host.docker.internal:8090/v1 \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/proxy_server_config.yaml:/app/config.yaml \ my-app:latest \ --config /app/config.yaml \ - --port 4000 \ - --detailed_debug \ + --port 4000 - run: name: Start outputting logs command: docker logs -f my-app @@ -1787,6 +1832,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1832,13 +1878,13 @@ jobs: -e LANGFUSE_PROJECT2_PUBLIC=$LANGFUSE_PROJECT2_PUBLIC \ -e LANGFUSE_PROJECT1_SECRET=$LANGFUSE_PROJECT1_SECRET \ -e LANGFUSE_PROJECT2_SECRET=$LANGFUSE_PROJECT2_SECRET \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/litellm/proxy/example_config_yaml/oai_misc_config.yaml:/app/config.yaml \ litellm-docker-database:ci \ --config /app/config.yaml \ - --port 4000 \ - --detailed_debug \ + --port 4000 - run: name: Start outputting logs command: docker logs -f my-app @@ -1869,6 +1915,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -1911,14 +1958,14 @@ jobs: -e COHERE_API_KEY=$COHERE_API_KEY \ -e RECORDER_COHERE_BASE_URL=http://host.docker.internal:8090/__recorder_upstream/api.cohere.com \ -e GCS_FLUSH_INTERVAL="1" \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/litellm/proxy/example_config_yaml/otel_test_config.yaml:/app/config.yaml \ -v $(pwd)/litellm/proxy/example_config_yaml/custom_guardrail.py:/app/custom_guardrail.py \ litellm-docker-database:ci \ --config /app/config.yaml \ - --port 4000 \ - --detailed_debug \ + --port 4000 - run: name: Start outputting logs command: docker logs -f my-app @@ -1960,13 +2007,13 @@ jobs: -e OPENAI_API_KEY=$OPENAI_API_KEY \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ -e LITELLM_LICENSE="bad-license" \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app-3 \ -v $(pwd)/litellm/proxy/example_config_yaml/enterprise_config.yaml:/app/config.yaml \ litellm-docker-database:ci \ --config /app/config.yaml \ - --port 4000 \ - --detailed_debug + --port 4000 - run: name: Start outputting logs for second container @@ -2000,6 +2047,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -2041,13 +2089,13 @@ jobs: -e DD_SITE=$DD_SITE \ -e AWS_REGION_NAME=$AWS_REGION_NAME \ -e PROXY_BATCH_WRITE_AT=2 \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/litellm/proxy/example_config_yaml/spend_tracking_config.yaml:/app/config.yaml \ litellm-docker-database:ci \ --config /app/config.yaml \ - --port 4000 \ - --detailed_debug \ + --port 4000 - run: name: Start outputting logs command: docker logs -f my-app @@ -2085,6 +2133,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -2117,13 +2166,13 @@ jobs: -e USE_DDTRACE=True \ -e DD_API_KEY=$DD_API_KEY \ -e DD_SITE=$DD_SITE \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/litellm/proxy/example_config_yaml/multi_instance_simple_config.yaml:/app/config.yaml \ litellm-docker-database:ci \ --config /app/config.yaml \ - --port 4000 \ - --detailed_debug \ + --port 4000 - run: name: Run Docker container 2 command: | @@ -2139,13 +2188,13 @@ jobs: -e USE_DDTRACE=True \ -e DD_API_KEY=$DD_API_KEY \ -e DD_SITE=$DD_SITE \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app-2 \ -v $(pwd)/litellm/proxy/example_config_yaml/multi_instance_simple_config.yaml:/app/config.yaml \ litellm-docker-database:ci \ --config /app/config.yaml \ - --port 4001 \ - --detailed_debug + --port 4001 - run: name: Start outputting logs command: docker logs -f my-app @@ -2180,6 +2229,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -2201,19 +2251,20 @@ jobs: # the OTEL test - should get this as a trace command: | docker run -d \ + --restart on-failure \ -p 4000:4000 \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ -e STORE_MODEL_IN_DB="True" \ -e LITELLM_MASTER_KEY="sk-1234" \ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \ -e LITELLM_LICENSE=$LITELLM_LICENSE \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/litellm/proxy/example_config_yaml/store_model_db_config.yaml:/app/config.yaml \ litellm-docker-database:ci \ --config /app/config.yaml \ - --port 4000 \ - --detailed_debug \ + --port 4000 - run: name: Start outputting logs command: docker logs -f my-app @@ -2252,6 +2303,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - setup_google_dns # Remove Docker CLI installation since it's already available in machine executor - install_uv @@ -2289,13 +2341,13 @@ jobs: -e DD_API_KEY=$DD_API_KEY \ -e DD_SITE=$DD_SITE \ -e GCS_FLUSH_INTERVAL="1" \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/docker/build_from_pip/litellm_config.yaml:/app/config.yaml \ my-app:latest \ --config /app/config.yaml \ - --port 4000 \ - --detailed_debug \ + --port 4000 - run: name: Start outputting logs command: docker logs -f my-app @@ -2333,6 +2385,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -2365,14 +2418,14 @@ jobs: -e DD_SITE=$DD_SITE \ -e LITELLM_LICENSE=$LITELLM_LICENSE \ -e LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES=true \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/litellm/proxy/example_config_yaml/pass_through_config.yaml:/app/config.yaml \ -v $(pwd)/litellm/proxy/example_config_yaml/custom_auth_basic.py:/app/custom_auth_basic.py \ litellm-docker-database:ci \ --config /app/config.yaml \ - --port 4000 \ - --detailed_debug \ + --port 4000 - run: name: Start outputting logs command: docker logs -f my-app @@ -2471,6 +2524,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - setup_google_dns - install_uv - run: @@ -2499,13 +2553,13 @@ jobs: -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \ -e AWS_REGION_NAME="us-east-1" \ -e LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS="True" \ + -e LITELLM_LOG=ERROR \ --add-host host.docker.internal:host-gateway \ --name my-app \ -v $(pwd)/tests/proxy_e2e_anthropic_messages_tests/test_config.yaml:/app/config.yaml \ litellm-docker-database:ci \ --config /app/config.yaml \ - --port 4000 \ - --detailed_debug + --port 4000 - run: name: Start outputting logs command: docker logs -f my-app @@ -2537,6 +2591,7 @@ jobs: - *python312_image steps: - checkout + - skip_if_unrelated_changes - attach_workspace: at: . # Check file locations @@ -2567,6 +2622,8 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes: + category: client - setup_google_dns - restore_cache: keys: @@ -2609,6 +2666,8 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes: + category: client - setup_google_dns - restore_cache: keys: @@ -2629,7 +2688,7 @@ jobs: cd ui/litellm-dashboard CI=true npm run test -- --run \ - --pool forks --poolOptions.forks.maxForks=8 + --pool forks --poolOptions.forks.maxForks=6 e2e_ui_testing: docker: @@ -2654,6 +2713,8 @@ jobs: PROXY_LOGOUT_URL: "https://www.example.com" steps: - checkout + - skip_if_unrelated_changes: + category: client - setup_google_dns - install_uv - restore_cache: @@ -2791,6 +2852,8 @@ jobs: SERVER_ROOT_PATH: "/litellm" steps: - checkout + - skip_if_unrelated_changes: + category: client - setup_google_dns - install_uv - restore_cache: @@ -2892,6 +2955,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - run: name: Build Docker image @@ -2917,6 +2981,7 @@ jobs: working_directory: ~/project steps: - checkout + - skip_if_unrelated_changes - attach_workspace: at: ~/project - setup_google_dns diff --git a/.circleci/scripts/classify_changes.sh b/.circleci/scripts/classify_changes.sh new file mode 100755 index 00000000000..2c15428be6a --- /dev/null +++ b/.circleci/scripts/classify_changes.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -uo pipefail + +category="${1:?usage: classify_changes.sh }" + +has_client=false +has_backend=false +while IFS= read -r file || [ -n "$file" ]; do + [ -n "$file" ] || continue + case "$file" in + ui/*) has_client=true ;; + docs/* | *.md | *.mdx) : ;; + *) has_backend=true ;; + esac +done + +case "$category" in + backend) + [ "$has_backend" = true ] && echo run || echo skip + ;; + client) + { [ "$has_client" = true ] || [ "$has_backend" = true ]; } && echo run || echo skip + ;; + *) + echo run + ;; +esac diff --git a/.circleci/scripts/path_filter.sh b/.circleci/scripts/path_filter.sh new file mode 100755 index 00000000000..dcf64a24399 --- /dev/null +++ b/.circleci/scripts/path_filter.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -uo pipefail + +category="${1:?usage: path_filter.sh }" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +run_full() { + echo "path-filter[$category]: running job ($1)" + exit 0 +} + +[ -n "${CIRCLE_PULL_REQUEST:-}" ] || run_full "not a pull request" + +candidate_bases="main litellm_internal_staging litellm_oss_staging" +merge_base="" +for base in $candidate_bases; do + git fetch --quiet origin "$base" 2>/dev/null || continue + candidate="$(git merge-base HEAD FETCH_HEAD 2>/dev/null)" || continue + [ -n "$candidate" ] || continue + if [ -z "$merge_base" ] || git merge-base --is-ancestor "$merge_base" "$candidate" 2>/dev/null; then + merge_base="$candidate" + fi +done + +[ -n "$merge_base" ] || run_full "could not resolve a merge base against $candidate_bases" + +changed="$(git diff --name-only "$merge_base" HEAD 2>/dev/null)" || run_full "git diff failed" +[ -n "$changed" ] || run_full "no files changed vs $merge_base" + +echo "path-filter[$category]: changed files vs ${merge_base}:" +printf '%s\n' "$changed" | sed 's/^/ /' || true + +decision="$(printf '%s\n' "$changed" | bash "$here/classify_changes.sh" "$category")" || run_full "classify_changes.sh failed" + +if [ "$decision" = run ]; then + run_full "$category-relevant changes detected" +fi + +echo "path-filter[$category]: only unrelated (docs/client) changes detected; halting job as successful" +circleci-agent step halt diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..1a29c0b6691 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +/ui/ @yuneng-jiang @ryan-crabbe-berri +/litellm/proxy/_experimental/out/ @yuneng-jiang @ryan-crabbe-berri diff --git a/.github/actions/detect-backend-changes/action.yml b/.github/actions/detect-backend-changes/action.yml new file mode 100644 index 00000000000..af01038f294 --- /dev/null +++ b/.github/actions/detect-backend-changes/action.yml @@ -0,0 +1,48 @@ +name: "Detect backend-relevant changes" +description: >- + Classify the pull request's changed files with .circleci/scripts/classify_changes.sh + and expose decision=run|skip. decision=skip means only ui/**, **.md or **.mdx files + changed, so callers can short-circuit expensive steps while the job still completes + successfully and satisfies its required status check. The decision defaults to run for + any non pull_request event or whenever the changed set cannot be resolved, so tests are + never skipped when the classification is uncertain. + +outputs: + decision: + description: "run when backend-relevant files changed, otherwise skip" + value: ${{ steps.classify.outputs.decision }} + +runs: + using: composite + steps: + - id: classify + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -uo pipefail + if [ -z "${BASE_SHA:-}" ]; then + echo "detect-backend-changes: not a pull_request event; running job" + echo "decision=run" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if ! git fetch --no-tags --depth=1 origin "${BASE_SHA}" >/dev/null 2>&1; then + echo "detect-backend-changes: could not fetch base ${BASE_SHA}; running job" + echo "decision=run" >> "${GITHUB_OUTPUT}" + exit 0 + fi + changed="$(git diff --name-only "${BASE_SHA}" HEAD 2>/dev/null)" || { + echo "detect-backend-changes: git diff failed; running job" + echo "decision=run" >> "${GITHUB_OUTPUT}" + exit 0 + } + if [ -z "${changed}" ]; then + echo "detect-backend-changes: no changed files vs ${BASE_SHA}; skipping job" + echo "decision=skip" >> "${GITHUB_OUTPUT}" + exit 0 + fi + echo "detect-backend-changes: changed files vs ${BASE_SHA}:" + printf '%s\n' "${changed}" | sed 's/^/ /' + decision="$(printf '%s\n' "${changed}" | bash .circleci/scripts/classify_changes.sh backend)" || decision="run" + echo "detect-backend-changes: decision=${decision}" + echo "decision=${decision}" >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/setup-uv-with-retries/action.yml b/.github/actions/setup-uv-with-retries/action.yml new file mode 100644 index 00000000000..1627038dc3d --- /dev/null +++ b/.github/actions/setup-uv-with-retries/action.yml @@ -0,0 +1,47 @@ +name: "Set up uv with retries" +description: >- + Install uv via astral-sh/setup-uv, retrying on transient failures. Even with + an exact pinned version, the action resolves the artifact URL by fetching + https://raw.githubusercontent.com/astral-sh/versions/main/v1/uv.ndjson in a + single request with no retry, timeout, or fallback, so one connection-level + network error ("fetch failed") fails the whole job before any test runs. + Retrying the full step covers the manifest fetch and the binary download. + +inputs: + version: + description: "uv version to install" + required: true + +runs: + using: composite + steps: + - name: Set up uv (attempt 1) + id: attempt-1 + continue-on-error: true + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + version: ${{ inputs.version }} + + - name: Wait before attempt 2 + if: steps.attempt-1.outcome == 'failure' + shell: bash + run: sleep 15 + + - name: Set up uv (attempt 2) + id: attempt-2 + if: steps.attempt-1.outcome == 'failure' + continue-on-error: true + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + version: ${{ inputs.version }} + + - name: Wait before attempt 3 + if: steps.attempt-2.outcome == 'failure' + shell: bash + run: sleep 30 + + - name: Set up uv (attempt 3) + if: steps.attempt-2.outcome == 'failure' + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + version: ${{ inputs.version }} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 12ad124fa20..d7e80b32749 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,7 +4,7 @@ ## Linear ticket - + ## Pre-Submission checklist @@ -13,7 +13,7 @@ - [ ] I have added meaningful tests - [ ] My PR passes all CI/CD checks (e.g., lint, format, unit tests) - [ ] My PR's scope is as isolated as possible; it only solves 1 specific problem -- [ ] I have requested a Greptile review by commenting `@greptileai` and received a **Confidence Score of at least 4/5** before requesting a maintainer review +- [ ] I have received a Greptile **Confidence Score of at least 4/5** before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment `@greptileai` to re-request a review after pushing changes) ## Delays in PR merge? @@ -24,6 +24,7 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac @@ -40,3 +41,27 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac ✅ Test ## Changes + +## QA runbook + + + +### Final Attestation + +- [ ] The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR diff --git a/.github/workflows/_test-unit-base.yml b/.github/workflows/_test-unit-base.yml index 25c6d4a7019..92230fc8892 100644 --- a/.github/workflows/_test-unit-base.yml +++ b/.github/workflows/_test-unit-base.yml @@ -45,19 +45,25 @@ jobs: name: Run tests runs-on: ubuntu-latest timeout-minutes: ${{ inputs.timeout-minutes }} + outputs: + decision: ${{ steps.changes.outputs.decision }} steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false + - name: Detect backend-relevant changes + id: changes + uses: ./.github/actions/detect-backend-changes + - 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 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" @@ -72,16 +78,19 @@ jobs: ${{ runner.os }}-uv- - name: Install dependencies + if: steps.changes.outputs.decision != 'skip' run: | .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router - name: Generate Prisma client + if: steps.changes.outputs.decision != 'skip' env: PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache run: | uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma - name: Run tests + if: steps.changes.outputs.decision != 'skip' env: TEST_PATH: ${{ inputs.test-path }} MAX_FAILURES: ${{ inputs.max-failures }} @@ -114,7 +123,7 @@ jobs: fi - name: Save coverage report - if: always() + if: always() && steps.changes.outputs.decision != 'skip' uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: coverage-${{ inputs.artifact-name }}-${{ github.run_id }}-${{ github.run_attempt }} @@ -124,7 +133,7 @@ jobs: upload-coverage: name: Upload coverage to Codecov needs: run - if: always() + if: always() && needs.run.outputs.decision != 'skip' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/auto_update_price_and_context_window.yml b/.github/workflows/auto_update_price_and_context_window.yml index 1c6c318c717..1a638a4a331 100644 --- a/.github/workflows/auto_update_price_and_context_window.yml +++ b/.github/workflows/auto_update_price_and_context_window.yml @@ -18,7 +18,7 @@ jobs: with: persist-credentials: false - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" - name: Update JSON Data diff --git a/.github/workflows/check-ui-api-types.yml b/.github/workflows/check-ui-api-types.yml index 439126aa1ee..9c24bad00f1 100644 --- a/.github/workflows/check-ui-api-types.yml +++ b/.github/workflows/check-ui-api-types.yml @@ -31,7 +31,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 49f1d906069..54a8e53d7a3 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - litellm_internal_staging pull_request: branches: - main + - litellm_internal_staging # Allow CodSpeed to trigger backtest performance analysis # in order to generate initial data workflow_dispatch: @@ -22,7 +24,7 @@ concurrency: jobs: benchmarks: runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 60 steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 @@ -35,7 +37,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 4834775e329..0ad84cd3ceb 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -122,10 +122,28 @@ jobs: makeLatest = (!latestVersion || isAtLeast(newVersion, latestVersion)) ? "true" : "false"; } + try { + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/tags/${tag}`, + sha: commitHash, + }); + } catch (error) { + if (error.status !== 422) throw error; + const existing = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${tag}`, + }); + if (existing.data.object.sha !== commitHash) { + throw new Error(`Tag ${tag} already exists at ${existing.data.object.sha}, expected ${commitHash}`); + } + } + const response = await github.rest.repos.createRelease({ draft: true, generate_release_notes: true, - target_commitish: commitHash, name: tag, owner: context.repo.owner, prerelease: isPrerelease, @@ -138,11 +156,21 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, release_id: response.data.id, + tag_name: tag, body: updatedBody, draft: false, - make_latest: makeLatest, }); + if (!isPrerelease) { + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: response.data.id, + tag_name: tag, + make_latest: makeLatest, + }); + } + } catch (error) { core.setFailed(error.message); } diff --git a/.github/workflows/create_daily_oss_branch.yml b/.github/workflows/create_daily_oss_branch.yml new file mode 100644 index 00000000000..43de4a0e75f --- /dev/null +++ b/.github/workflows/create_daily_oss_branch.yml @@ -0,0 +1,61 @@ +name: Create Daily OSS Branch + +on: + schedule: + - cron: "0 16 * * 1-5" # 9am PT during daylight saving time, weekdays. + workflow_dispatch: + inputs: + date: + description: "Branch date in YYYY_MM_DD format. Defaults to today's UTC date." + required: false + type: string + +permissions: + contents: write + +jobs: + create-oss-branch: + if: github.repository == 'BerriAI/litellm' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Create dated OSS branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REQUESTED_DATE: ${{ inputs.date }} + run: | + set -euo pipefail + + if [ -n "${REQUESTED_DATE}" ]; then + if ! echo "${REQUESTED_DATE}" | grep -Eq '^[0-9]{4}_[0-9]{2}_[0-9]{2}$'; then + echo "::error::date must use YYYY_MM_DD format, got '${REQUESTED_DATE}'" + exit 1 + fi + BRANCH_DATE="${REQUESTED_DATE}" + else + BRANCH_DATE="$(date -u +'%Y_%m_%d')" + fi + + BRANCH_NAME="litellm_oss_daily_${BRANCH_DATE}" + echo "Creating branch: ${BRANCH_NAME}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git fetch origin main "${BRANCH_NAME}" || true + + if git show-ref --verify --quiet "refs/remotes/origin/${BRANCH_NAME}"; then + echo "Branch ${BRANCH_NAME} already exists. Skipping creation." + exit 0 + fi + + git checkout -b "${BRANCH_NAME}" origin/main + git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${BRANCH_NAME}" + echo "Successfully created and pushed branch: ${BRANCH_NAME}" diff --git a/.github/workflows/guard-main-branch.yml b/.github/workflows/guard-main-branch.yml index 21aad18d298..aa4968f0c1e 100644 --- a/.github/workflows/guard-main-branch.yml +++ b/.github/workflows/guard-main-branch.yml @@ -31,12 +31,12 @@ jobs: echo "PR head repo: $HEAD_REPO" echo "PR head branch: $HEAD_REF" if [ "$HEAD_REPO" != "$BASE_REPO" ]; then - echo "::error::PRs to main must originate from the canonical repository ($BASE_REPO), not a fork ($HEAD_REPO). External contributors should open PRs against the 'litellm_oss_staging' branch instead." + echo "::error::PRs to main must originate from the canonical repository ($BASE_REPO), not a fork ($HEAD_REPO). External contributors should open PRs against the current daily OSS branch (named litellm_oss_daily_YYYY_MM_DD; a fresh one is cut each weekday, so target the most recent) instead." exit 1 fi if [ "$HEAD_REF" = "litellm_internal_staging" ] || [[ "$HEAD_REF" == litellm_hotfix_?* ]]; then echo "Allowed source branch." exit 0 fi - echo "::error::PRs to main must originate from 'litellm_internal_staging' or a 'litellm_hotfix_*' branch. Got: '$HEAD_REF'. If this is a contribution, retarget the PR against 'litellm_oss_staging' instead." + echo "::error::PRs to main must originate from 'litellm_internal_staging' or a 'litellm_hotfix_*' branch. Got: '$HEAD_REF'. If this is a contribution, retarget the PR against the current daily OSS branch (named litellm_oss_daily_YYYY_MM_DD; a fresh one is cut each weekday, so target the most recent) instead." exit 1 diff --git a/.github/workflows/helm_unit_test.yml b/.github/workflows/helm_unit_test.yml index 06836b1d1cd..5b9d20d97f3 100644 --- a/.github/workflows/helm_unit_test.yml +++ b/.github/workflows/helm_unit_test.yml @@ -38,4 +38,6 @@ jobs: echo "Helm unittest plugin integrity verified: $ACTUAL_SHA" - name: Run unit tests - run: helm unittest -f 'tests/*.yaml' deploy/charts/litellm-helm + run: | + helm unittest -f 'tests/*.yaml' helm/litellm-helm + helm unittest -f 'tests/*.yaml' helm/litellm diff --git a/.github/workflows/mutation-test.yml b/.github/workflows/mutation-test.yml index 183f12f969c..6684952b998 100644 --- a/.github/workflows/mutation-test.yml +++ b/.github/workflows/mutation-test.yml @@ -39,7 +39,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/oss_daily_guardrails.yml b/.github/workflows/oss_daily_guardrails.yml new file mode 100644 index 00000000000..f9dc746ee05 --- /dev/null +++ b/.github/workflows/oss_daily_guardrails.yml @@ -0,0 +1,50 @@ +name: OSS Daily Guardrails + +on: + push: + branches: + - "litellm_oss_daily_20*" + pull_request: + branches: + - "litellm_oss_daily_20*" + - litellm_internal_staging + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + oss-safe-checks: + name: Run OSS daily safe checks + if: startsWith(github.ref_name, 'litellm_oss_daily_20') || startsWith(github.head_ref, 'litellm_oss_daily_20') || startsWith(github.base_ref, 'litellm_oss_daily_20') + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: ./.github/actions/setup-uv-with-retries + with: + version: "0.10.9" + + - name: Run secret scan test + run: | + uv run --frozen --with 'pytest==9.0.2' pytest tests/litellm/test_no_hardcoded_secrets.py -v + + - name: Run Ruff + run: | + uv sync --frozen + cd litellm + uv run --no-sync ruff check . diff --git a/.github/workflows/test-code-quality.yml b/.github/workflows/test-code-quality.yml index 872a1799d98..9d28ca211cf 100644 --- a/.github/workflows/test-code-quality.yml +++ b/.github/workflows/test-code-quality.yml @@ -38,7 +38,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml index 6deb28c95c7..09406d77634 100644 --- a/.github/workflows/test-linting.yml +++ b/.github/workflows/test-linting.yml @@ -33,7 +33,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" @@ -48,7 +48,7 @@ jobs: - name: Install dependencies run: | - uv sync --frozen --group proxy-dev + uv sync --frozen --group proxy-dev --group e2e-dev # basedpyright resolves Prisma's generated client (litellm/proxy/schema.prisma) # only after `prisma generate` writes prisma/client.py et al. Without this the @@ -63,7 +63,7 @@ jobs: env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | - git diff --name-only "$BASE_SHA"...HEAD -- 'litellm/**/*.py' | grep -v '^litellm/enterprise/' > "$RUNNER_TEMP/ruff_format_files.txt" || true + git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- 'litellm/**/*.py' | grep -v '^litellm/enterprise/' > "$RUNNER_TEMP/ruff_format_files.txt" || true if [ ! -s "$RUNNER_TEMP/ruff_format_files.txt" ]; then echo "No changed litellm Python files to check with ruff format." exit 0 @@ -107,6 +107,16 @@ jobs: run: | (uv run --no-sync basedpyright --outputjson || true) | uv run --no-sync python scripts/type_check_gate.py --base "$BASE_SHA" + - name: Check tests/e2e basedpyright (zero errors) + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if git diff --name-only --diff-filter=ACMRD "$BASE_SHA"...HEAD -- 'tests/e2e/**/*.py' | grep -q .; then + uv run --no-sync basedpyright tests/e2e + else + echo "No changed tests/e2e Python files; skipping." + fi + - name: Check for circular imports run: | cd litellm @@ -162,7 +172,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-litellm-ui-build.yml b/.github/workflows/test-litellm-ui-build.yml index ce8d8cb9c95..525e2c5b949 100644 --- a/.github/workflows/test-litellm-ui-build.yml +++ b/.github/workflows/test-litellm-ui-build.yml @@ -36,79 +36,3 @@ jobs: - name: Build run: npm run build - - frontend-lint: - runs-on: ubuntu-latest - timeout-minutes: 8 - defaults: - run: - working-directory: ui/litellm-dashboard - - steps: - - name: Checkout repository - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Collect changed files - id: changed - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - : > "$RUNNER_TEMP/prettier_files.txt" - : > "$RUNNER_TEMP/eslint_files.txt" - while IFS= read -r f; do - [ -f "$f" ] || continue - case "$f" in - *.js | *.jsx | *.ts | *.tsx | *.mjs | *.cjs) - printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt" - printf '%s\n' "$f" >> "$RUNNER_TEMP/eslint_files.txt" ;; - *.json | *.css | *.scss | *.md | *.mdx | *.yml | *.yaml | *.html) - printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt" ;; - esac - done < <(git diff --name-only --diff-filter=ACMR --relative "$BASE_SHA"...HEAD -- .) - if [ -s "$RUNNER_TEMP/prettier_files.txt" ] || [ -s "$RUNNER_TEMP/eslint_files.txt" ]; then - echo "has_files=true" >> "$GITHUB_OUTPUT" - else - echo "has_files=false" >> "$GITHUB_OUTPUT" - echo "No lintable UI files changed in this PR; nothing to check." - fi - - - name: Setup Node.js - if: steps.changed.outputs.has_files == 'true' - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/litellm-dashboard/package-lock.json - - - name: Install dependencies - if: steps.changed.outputs.has_files == 'true' - run: npm ci - - - name: Lint changed files (prettier + eslint) - if: steps.changed.outputs.has_files == 'true' - run: | - prettier_files=() - eslint_files=() - while IFS= read -r f; do prettier_files+=("$f"); done < "$RUNNER_TEMP/prettier_files.txt" - while IFS= read -r f; do eslint_files+=("$f"); done < "$RUNNER_TEMP/eslint_files.txt" - status=0 - if [ ${#prettier_files[@]} -gt 0 ]; then - echo "::group::Prettier (${#prettier_files[@]} files)" - npx prettier --check "${prettier_files[@]}" || { status=1; echo "::error::Unformatted files. Fix with: npm run format"; } - echo "::endgroup::" - fi - if [ ${#eslint_files[@]} -gt 0 ]; then - echo "::group::ESLint (${#eslint_files[@]} files)" - npx eslint --no-warn-ignored --pass-on-unpruned-suppressions "${eslint_files[@]}" || status=1 - echo "::endgroup::" - fi - exit $status - - - name: Check lint budgets - if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }} - run: | - npx eslint . -f json -o "$RUNNER_TEMP/lint-report.json" || true - node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json --check eslint-metrics.json diff --git a/.github/workflows/test-litellm-ui-lint.yml b/.github/workflows/test-litellm-ui-lint.yml new file mode 100644 index 00000000000..804894b1e50 --- /dev/null +++ b/.github/workflows/test-litellm-ui-lint.yml @@ -0,0 +1,92 @@ +name: UI Lint +permissions: + contents: read + +on: + pull_request: + branches: + - main + - litellm_internal_staging + - litellm_oss_staging + - "litellm_**" + +jobs: + frontend-lint: + runs-on: ubuntu-latest + timeout-minutes: 8 + defaults: + run: + working-directory: ui/litellm-dashboard + + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Collect changed files + id: changed + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + : > "$RUNNER_TEMP/prettier_files.txt" + : > "$RUNNER_TEMP/eslint_files.txt" + while IFS= read -r f; do + [ -f "$f" ] || continue + case "$f" in + *.js | *.jsx | *.ts | *.tsx | *.mjs | *.cjs) + printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt" + printf '%s\n' "$f" >> "$RUNNER_TEMP/eslint_files.txt" ;; + *.json | *.css | *.scss | *.md | *.mdx | *.yml | *.yaml | *.html) + printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt" ;; + esac + done < <(git diff --name-only --diff-filter=ACMR --relative "$BASE_SHA"...HEAD -- .) + if [ -s "$RUNNER_TEMP/prettier_files.txt" ] || [ -s "$RUNNER_TEMP/eslint_files.txt" ]; then + echo "has_files=true" >> "$GITHUB_OUTPUT" + else + echo "has_files=false" >> "$GITHUB_OUTPUT" + echo "No lintable UI files changed in this PR; nothing to check." + fi + + - name: Setup Node.js + if: steps.changed.outputs.has_files == 'true' + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/litellm-dashboard/package-lock.json + + - name: Install dependencies + if: steps.changed.outputs.has_files == 'true' + run: npm ci + + - name: Lint changed files (prettier + eslint) + if: steps.changed.outputs.has_files == 'true' + run: | + prettier_files=() + eslint_files=() + while IFS= read -r f; do prettier_files+=("$f"); done < "$RUNNER_TEMP/prettier_files.txt" + while IFS= read -r f; do eslint_files+=("$f"); done < "$RUNNER_TEMP/eslint_files.txt" + status=0 + if [ ${#prettier_files[@]} -gt 0 ]; then + echo "::group::Prettier (${#prettier_files[@]} files)" + npx prettier --check "${prettier_files[@]}" || { status=1; echo "::error::Unformatted files. Fix with: npm run format"; } + echo "::endgroup::" + fi + if [ ${#eslint_files[@]} -gt 0 ]; then + echo "::group::ESLint (${#eslint_files[@]} files)" + npx eslint --no-warn-ignored --pass-on-unpruned-suppressions "${eslint_files[@]}" || status=1 + echo "::endgroup::" + fi + exit $status + + - name: Check lint budgets + if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }} + run: | + npx eslint . -f json -o "$RUNNER_TEMP/lint-report.json" || true + node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json + + - name: Check for dead code (knip) + if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }} + run: npm run knip:ci diff --git a/.github/workflows/test-mcp.yml b/.github/workflows/test-mcp.yml index 5b5290880c1..a5a4e722133 100644 --- a/.github/workflows/test-mcp.yml +++ b/.github/workflows/test-mcp.yml @@ -32,7 +32,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-semgrep.yml b/.github/workflows/test-semgrep.yml index f0dcb9887be..6e9f5e42fa2 100644 --- a/.github/workflows/test-semgrep.yml +++ b/.github/workflows/test-semgrep.yml @@ -31,7 +31,7 @@ jobs: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" diff --git a/.github/workflows/test-terraform-provider.yml b/.github/workflows/test-terraform-provider.yml new file mode 100644 index 00000000000..058a2538c15 --- /dev/null +++ b/.github/workflows/test-terraform-provider.yml @@ -0,0 +1,113 @@ +name: Terraform Provider + +on: + push: + paths: + - "terraform/provider/**" + - ".github/workflows/test-terraform-provider.yml" + pull_request: + branches: + - main + - litellm_internal_staging + - litellm_oss_staging + - "litellm_**" + paths: + - "terraform/provider/**" + - "litellm/proxy/**" + - ".github/workflows/test-terraform-provider.yml" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + provider-checks: + name: gofmt, vet, build, test + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: terraform/provider + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version-file: terraform/provider/go.mod + cache: true + cache-dependency-path: terraform/provider/go.sum + + - name: gofmt + run: | + UNFORMATTED=$(gofmt -l .) + if [ -n "${UNFORMATTED}" ]; then + echo "::error::gofmt required for: ${UNFORMATTED}" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: Build + run: go build ./... + + - name: Test + run: go test -timeout 120s ./... + + endpoint-drift: + name: Provider endpoints vs proxy OpenAPI schema + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: ./.github/actions/setup-uv-with-retries + with: + version: "0.10.9" + + - name: 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: | + .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router + + - name: Generate Prisma client + env: + PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache + run: | + uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma + + - name: Generate proxy OpenAPI schema + run: | + uv run --no-sync python terraform/provider/tools/dump_openapi.py "${RUNNER_TEMP}/openapi.json" + + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version-file: terraform/provider/go.mod + cache: true + cache-dependency-path: terraform/provider/go.sum + + - name: Audit provider endpoints against the schema + working-directory: terraform/provider + run: go run ./tools/endpointaudit -provider-dir ./litellm -spec "${RUNNER_TEMP}/openapi.json" diff --git a/.github/workflows/test-unit-documentation.yml b/.github/workflows/test-unit-documentation.yml index 4cef791a9b3..c12a289ce9f 100644 --- a/.github/workflows/test-unit-documentation.yml +++ b/.github/workflows/test-unit-documentation.yml @@ -32,13 +32,17 @@ jobs: path: docs/my-website persist-credentials: false + - name: Detect backend-relevant changes + id: changes + uses: ./.github/actions/detect-backend-changes + - 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 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" @@ -53,10 +57,12 @@ jobs: ${{ runner.os }}-uv- - name: Install dependencies + if: steps.changes.outputs.decision != 'skip' run: | .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router - name: Generate Prisma client + if: steps.changes.outputs.decision != 'skip' env: PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache run: | @@ -64,6 +70,7 @@ jobs: # Run the same documentation tests that CircleCI ran (as direct Python scripts) - name: Run documentation validation tests + if: steps.changes.outputs.decision != 'skip' run: | uv run --no-sync python ./tests/documentation_tests/test_env_keys.py uv run --no-sync python ./tests/documentation_tests/test_router_settings.py diff --git a/.github/workflows/test-unit-proxy-legacy.yml b/.github/workflows/test-unit-proxy-legacy.yml index 8db218cd1fc..bcbf365babf 100644 --- a/.github/workflows/test-unit-proxy-legacy.yml +++ b/.github/workflows/test-unit-proxy-legacy.yml @@ -49,13 +49,17 @@ jobs: with: persist-credentials: false + - name: Detect backend-relevant changes + id: changes + uses: ./.github/actions/detect-backend-changes + - 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 + uses: ./.github/actions/setup-uv-with-retries with: version: "0.10.9" @@ -70,16 +74,19 @@ jobs: ${{ runner.os }}-uv- - name: Install dependencies + if: steps.changes.outputs.decision != 'skip' run: | .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router - name: Generate Prisma client + if: steps.changes.outputs.decision != 'skip' env: PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache run: | uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma - name: Run tests - ${{ matrix.test-group.name }} + if: steps.changes.outputs.decision != 'skip' env: TEST_PATH: ${{ matrix.test-group.path }} run: | diff --git a/.github/workflows/test_server_root_path.yml b/.github/workflows/test_server_root_path.yml index ac363071d55..f59cee29893 100644 --- a/.github/workflows/test_server_root_path.yml +++ b/.github/workflows/test_server_root_path.yml @@ -16,6 +16,7 @@ jobs: timeout-minutes: 30 strategy: + fail-fast: false matrix: root_path: ["/api/v1", "/llmproxy"] @@ -108,8 +109,26 @@ jobs: - name: Install UI deps and Chromium working-directory: ui/litellm-dashboard run: | - npm ci - npx playwright install --with-deps chromium + retry() { + local attempt=1 + local max_attempts=4 + until "$@"; do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "Command failed after $attempt attempts: $*" + return 1 + fi + echo "Attempt $attempt failed: $*. Retrying in $((attempt * 15))s..." + sleep $((attempt * 15)) + attempt=$((attempt + 1)) + done + } + + npm config set fetch-retries 5 + npm config set fetch-retry-mintimeout 20000 + npm config set fetch-retry-maxtimeout 120000 + + retry npm ci + retry npx playwright install --with-deps chromium - name: Run SERVER_ROOT_PATH redirect e2e working-directory: ui/litellm-dashboard diff --git a/.gitignore b/.gitignore index 59fa5803abe..0c976a1a226 100644 --- a/.gitignore +++ b/.gitignore @@ -52,9 +52,8 @@ ui/litellm-dashboard/node_modules ui/litellm-dashboard/next-env.d.ts ui/litellm-dashboard/package.json ui/litellm-dashboard/package-lock.json -deploy/charts/litellm/*.tgz -deploy/charts/litellm/charts/* -deploy/charts/*.tgz +helm/litellm-helm/*.tgz +helm/*.tgz litellm/proxy/vertex_key.json **/.vim/ **/node_modules @@ -107,6 +106,13 @@ STABILIZATION_TODO.md **/coverage test-config +# Claude Code compatibility-matrix pytest artifact (CI-only output). +compat-results.json +compat-results.json.shards/ +compat-rate-limit-summary.json +# Matrix JSON produced by the daily-cron publisher (pushed to litellm-docs). +compatibility-matrix.json + # ---------- Terraform ---------- # Provider binaries + module cache — regenerated by `terraform init`. **/.terraform/ @@ -130,3 +136,5 @@ crash.*.log # pytest coverage data .coverage + +ui/litellm-dashboard/out/ diff --git a/CLAUDE.md b/CLAUDE.md index 7d9a6367f18..9f708716c6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,11 +17,15 @@ Same thing for bug fixes. The tests should make it so that this specific bug can `tests/test_litellm/` mirrors `litellm/` in a parallel path (see `tests/test_litellm/readme.md`). Name tests `test_.py`, but always match the existing test file in the directory you touch — many provider dirs use longer descriptive names (e.g. `test_anthropic_chat_transformation.py`) to avoid ambiguity across sibling folders. For bug fixes, extend the existing mapped test file rather than creating a new one. Only create a new test file for a new feature (provider, endpoint, or transformation module) that has no mapped test yet, following that directory's naming convention (or `test_.py` if you're the first test there). One focused regression test beats many shallow ones -When creating PRs, don't set base to `main`. `litellm_internal_staging` serves that purpose +End-to-end tests belong in `tests/e2e/` and must follow the harness conventions documented in that directory's `CLAUDE.md` -Always use @.github/pull_request_template.md as a guide for your PR body +When creating PRs, don't set base to `main`. `litellm_internal_staging` serves that purpose for internal contributors; external / OSS contributions target the current daily OSS branch instead, named `litellm_oss_daily_YYYY_MM_DD` (a fresh one is cut each weekday, so use the most recent) -Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR +When writing a PR body, treat the comments and imperative instructions inside @.github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule + +If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just leave the section blank + +Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR If you ever make public-facing PR descriptions, comments, issues, commit messages, etc., always follow these guidelines to sound less AI-y: - don't use emojis @@ -35,6 +39,8 @@ Don't hesitate to use values in .env to get needed API keys and other secrets, a Python max line length is 120, not 88 +On a fresh worktree or clone, run `make bootstrap` before anything else. It provisions everything tests, `make pre-commit`, and a local proxy need + Run tests before you commit. Also, run `make pre-commit` right before each commit, which generates types (as needed) and formats/lints your code. Any errors found must be fixed. It only runs when there are staged frontend and/or backend changes and calculates violations, generates types, etc. based on the worktree, so stage what you need or stash/delete unwanted files in litellm/ or ui/ (where backend and frontend lint run, respectively) before running it. If it fails because dashboard api types are stale, it already regenerated them for you. You just need to stage the schema.d.ts, re-run `make pre-commit` to confirm it passes, and commit When you fix violations gated by `ruff-strict-budget.json`, `type-discipline-budget.json`, or `basedpyright-code-budget.json`, run `make lint-budget-update` and commit the lowered limits so the ceilings ratchet down instead of leaving stale headroom. It measures the working tree, so it must contain exactly the fixes you're committing @@ -43,9 +49,11 @@ If you're trying to create a new function that relies on untyped stuff, instead If you get an LIT001 or LIT002 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally `# mutable-ok` is never used; reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason +Every lint or type suppression must name the exact rule inside brackets and carry a reason comment, e.g. `# pyright: ignore[reportArgumentType] # stubs lack async overload` or `# noqa: TID251 # `. `# type: ignore` is banned (LIT009): pyrightconfig.json sets `enableTypeIgnoreComments` to false, so it silently does nothing + Commit and push your work when you're done without asking -When you must use real LLM models to, for example, write e2e tests, write a QA runbook, etc., make sure to use the latest models (doesn't have to be smartest, can also be a modern small, fast one. No strong preference for smart vs fast here, just use something modern) as of the year and month of the current date. Do a web search as necessary to figure that out +When referencing or running models (coding, QA'ing, writing docs, writing tests, etc.), use the latest model in that model family unless otherwise specified; treat your training knowledge, memories, configs, and tests as stale, and determine the family's latest with model_prices_and_context_window.json or the web If you're an internal contributor, when creating a new PR, the typical flow is to branch off litellm_internal_staging and create a branch prefixed with litellm_. Do not create a branch prefixed with claude/ and generally do not have / in your branch names diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1080579d0fa..0202965ec4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -322,7 +322,7 @@ npm run build ## Submitting Your PR 1. **Push your branch**: `git push origin your-feature-branch` -2. **Create a PR**: Go to GitHub and create a pull request +2. **Create a PR**: Go to GitHub and open a pull request against the current daily OSS branch, named `litellm_oss_daily_YYYY_MM_DD`. A fresh one is cut each weekday, so pick the most recent from the [branch list](https://github.com/BerriAI/litellm/branches/all?query=litellm_oss_daily). Do not target `main`. 3. **Fill out the PR template**: Provide clear description of changes 4. **Wait for review**: Maintainers will review and provide feedback 5. **Address feedback**: Make requested changes and push updates diff --git a/Dockerfile b/Dockerfile index b6fef1a21fc..bc0e6a5ca6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ # syntax=docker/dockerfile:1.7 # Base image for building -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370 +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f # Runtime image -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370 +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a # Pinned by digest like the other base images; bump explicitly on Node upgrades. ARG UI_BUILD_IMAGE=node:20.18-alpine3.20@sha256:3488b10bf958af7125a176419d2d8a9937d895bf124012aae811651988d2ffe6 diff --git a/Makefile b/Makefile index 2cc4ec3e45a..8b657dcb465 100644 --- a/Makefile +++ b/Makefile @@ -4,16 +4,17 @@ .PHONY: help test test-unit test-unit-llms test-unit-proxy-guardrails test-unit-proxy-core test-unit-proxy-misc \ test-unit-integrations test-unit-core-utils test-unit-other test-unit-root \ test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \ - info lint lint-dev format \ - lint-basedpyright lint-basedpyright-budget-update lint-type-discipline lint-type-discipline-budget-update \ + info lint lint-dev lint-checks format \ + lint-basedpyright lint-e2e-basedpyright lint-basedpyright-budget-update lint-type-discipline lint-type-discipline-budget-update \ lint-ruff-budget lint-ruff-budget-update lint-budget-update lint-gate \ install-dev install-proxy-dev install-test-deps install-hooks \ install-helm-unittest check-circular-imports check-import-safety pre-commit \ - lint-install lint-fetch-base + lint-install lint-fetch-base bootstrap # Default target help: @echo "Available commands:" + @echo " make bootstrap - Provision a fresh clone/worktree" @echo " make install-dev - Install development dependencies" @echo " make install-proxy-dev - Install proxy development dependencies" @echo " make install-dev-ci - Install dev dependencies (CI-compatible, pins OpenAI)" @@ -27,6 +28,7 @@ help: @echo " make lint - Run all linting (Ruff, basedpyright, format check, circular imports, import safety)" @echo " make lint-ruff - Run Ruff linting only" @echo " make lint-basedpyright - Run basedpyright strict, gated by per-rule error counts" + @echo " make lint-e2e-basedpyright - Run basedpyright over tests/e2e (zero errors allowed)" @echo " make lint-basedpyright-budget-update - Ratchet basedpyright limits down by what this branch fixed" @echo " make lint-format - Check ruff format formatting (matches CI)" @echo " make lint-ruff-budget - Gate the codebase total of each strict ruff rule against its limit" @@ -53,6 +55,12 @@ help: UV := uv UV_RUN := $(UV) run --no-sync +LINT_DEP_INSTALL ?= install-dev +LINT_E2E_DEP_INSTALL ?= lint-install +LINT_DEP_BASE ?= lint-fetch-base +LINT_JOBS := $(shell sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4) +LINT_OUTPUT_SYNC := $(if $(filter output-sync,$(.FEATURES)),--output-sync=target,) + # Show info info: @echo "UV: $(UV)" @@ -64,6 +72,18 @@ info: install-dev: $(UV) sync --inexact --frozen +bootstrap: + $(UV) sync --inexact --frozen --extra proxy --group proxy-dev --group e2e-dev + $(UV_RUN) python scripts/prisma_generate_if_needed.py + cd ui/litellm-dashboard && npm ci --no-audit --no-fund + @main_root=$$(git worktree list --porcelain | head -1 | sed 's/^worktree //'); \ + if [ "$$main_root" != "$$(git rev-parse --show-toplevel)" ] && [ -f "$$main_root/.env" ] && [ ! -f .env ]; then \ + cp "$$main_root/.env" .env && echo "bootstrap: copied .env from $$main_root"; \ + else \ + echo "bootstrap: .env left untouched"; \ + fi + @echo "bootstrap: done" + install-proxy-dev: $(UV) sync --frozen --group proxy-dev --extra proxy @@ -106,13 +126,13 @@ lint-fetch-base: # CI's). --inexact tops up the venv instead of pruning the proxy extras gen:api and the # running proxy need. lint-install: - $(UV) sync --inexact --frozen --group proxy-dev - $(UV_RUN) prisma generate --schema litellm/proxy/schema.prisma + $(UV) sync --inexact --frozen --group proxy-dev --group e2e-dev + $(UV_RUN) python scripts/prisma_generate_if_needed.py # Diff-scoped format check, identical to test-linting.yml's "Check ruff format" step: # only the litellm Python files changed vs the base are checked, so a pre-existing # format issue elsewhere doesn't block an unrelated commit. -lint-format-check-changed: install-dev lint-fetch-base +lint-format-check-changed: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) @files=$$(git diff --name-only origin/litellm_internal_staging...HEAD -- 'litellm/**/*.py' | grep -v '^litellm/enterprise/' || true); \ if [ -z "$$files" ]; then \ echo "No changed litellm Python files to format-check."; \ @@ -121,7 +141,7 @@ lint-format-check-changed: install-dev lint-fetch-base fi # Linting targets -lint-ruff: install-dev +lint-ruff: $(LINT_DEP_INSTALL) cd litellm && $(UV_RUN) ruff check . && cd .. # faster linter for developing ... @@ -156,12 +176,15 @@ lint-ruff-FULL-dev: install-dev if [ -n "$$files" ]; then echo "$$files" | xargs $(UV_RUN) ruff check; \ else echo "No changed .py files to check."; fi -lint-basedpyright: install-dev lint-fetch-base +lint-basedpyright: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) ($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --base origin/litellm_internal_staging +lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL) + $(UV_RUN) basedpyright tests/e2e + # Type-discipline budget (mutable collections / casts / type guards / kwargs / # unexplained suppressions), the test-linting.yml step `make lint` used to omit. -lint-type-discipline: install-dev lint-fetch-base +lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) $(UV_RUN) python scripts/type_discipline_gate.py --base origin/litellm_internal_staging # --update lowers each limit by what this branch fixed since its branch point, so @@ -176,7 +199,7 @@ lint-ruff-budget: install-dev # Strict gate, invoked the same way CI does in test-linting.yml so a local pass # means the CI check will pass too. -lint-gate: install-dev lint-fetch-base +lint-gate: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) $(UV_RUN) python scripts/ruff_strict_gate.py --base origin/litellm_internal_staging lint-ruff-budget-update: install-dev lint-fetch-base @@ -188,10 +211,10 @@ lint-type-discipline-budget-update: install-dev lint-fetch-base # Ratchet all budgets in one shot (ruff strict + type-discipline + basedpyright) lint-budget-update: lint-ruff-budget-update lint-type-discipline-budget-update lint-basedpyright-budget-update -check-circular-imports: install-dev +check-circular-imports: $(LINT_DEP_INSTALL) cd litellm && $(UV_RUN) python ../tests/documentation_tests/test_circular_imports.py && cd .. -check-import-safety: install-dev +check-import-safety: $(LINT_DEP_INSTALL) @$(UV_RUN) python -c "from litellm import *; print('[from litellm import *] OK! no issues!');" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1) # Combined linting, isomorphic to test-linting.yml's lint job so a local pass means a @@ -199,9 +222,13 @@ check-import-safety: install-dev # runs the diff-scoped ruff format check, whole-tree ruff check, the strict-rule / # type-discipline / basedpyright budgets as a delta vs the base, then the circular-import # and import-safety checks. Steps that compare against the base resolve it the same way CI -# does (merge-base with origin/litellm_internal_staging). lint-install is first so the -# Prisma client exists before basedpyright runs. -lint: lint-install lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-basedpyright check-circular-imports check-import-safety +# does (merge-base with origin/litellm_internal_staging). Setup (env sync, Prisma client, +# base fetch) runs once up front; the checks themselves are independent, so a sub-make +# fans them out with -j and the fast ones finish under basedpyright's shadow. +lint: lint-install lint-fetch-base + $(MAKE) -j $(LINT_JOBS) $(LINT_OUTPUT_SYNC) LINT_DEP_INSTALL= LINT_E2E_DEP_INSTALL= LINT_DEP_BASE= lint-checks + +lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-basedpyright lint-e2e-basedpyright check-circular-imports check-import-safety # Faster linting for local development (only checks changed code) lint-dev: lint-format-changed check-circular-imports check-import-safety @@ -256,7 +283,7 @@ test-integration: install-test-deps $(UV_RUN) pytest tests/ -k "not test_litellm" test-unit-helm: install-helm-unittest - helm unittest -f 'tests/*.yaml' deploy/charts/litellm-helm + helm unittest -f 'tests/*.yaml' helm/litellm-helm # LLM Translation testing targets test-llm-translation: install-test-deps diff --git a/README.md b/README.md index 90d3e944fcc..32b0160dbaa 100644 --- a/README.md +++ b/README.md @@ -552,17 +552,12 @@ The Terraform modules live at [`terraform/litellm/aws/`](./terraform/litellm/aws 2. Run dependent services `docker-compose up db prometheus` #### Backend -1. (In root) create virtual environment `python -m venv .venv` -2. Activate virtual environment `source .venv/bin/activate` -3. Install dependencies `uv sync --all-extras --group proxy-dev` -4. `uv run prisma generate` -5. `prisma generate` -6. Start proxy backend `python litellm/proxy/proxy_cli.py` +1. Run `make bootstrap` +2. Start proxy backend: `uv run python litellm/proxy/proxy_cli.py` #### Frontend -1. Navigate to `ui/litellm-dashboard` -2. Install dependencies `npm install` -3. Run `npm run dev` to start the dashboard +1. Navigate to `ui/litellm-dashboard` (dependencies were already installed w/ `make bootstrap`) +2. Start dashboard: `npm run dev` ### Verify Docker Image Signatures diff --git a/backend/Dockerfile b/backend/Dockerfile index 667bdb073eb..62bd8b56483 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370 -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370 +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a FROM $UV_IMAGE AS uvbin diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py index b67f7d42127..02574ca505d 100644 --- a/backend/routes/allowlist.py +++ b/backend/routes/allowlist.py @@ -46,6 +46,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = ( "/fallback", "/fallbacks", "/cache_settings", + "/coordination_redis/", "/cost_tracking", "/cost/", "/credentials", diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 79e6af05978..edfb3536ad3 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -3,16 +3,16 @@ "limit": 37484 }, "reportArgumentType": { - "limit": 2721 + "limit": 2704 }, "reportAssignmentType": { "limit": 330 }, "reportAttributeAccessIssue": { - "limit": 519 + "limit": 516 }, "reportCallIssue": { - "limit": 131 + "limit": 124 }, "reportConstantRedefinition": { "limit": 59 @@ -42,7 +42,7 @@ "limit": 18 }, "reportIndexIssue": { - "limit": 39 + "limit": 37 }, "reportInvalidTypeForm": { "limit": 35 @@ -51,37 +51,37 @@ "limit": 5 }, "reportMatchNotExhaustive": { - "limit": 2 + "limit": 0 }, "reportMissingParameterType": { "limit": 5900 }, "reportMissingTypeArgument": { - "limit": 15918 + "limit": 15903 }, "reportMissingTypeStubs": { "limit": 41 }, "reportOperatorIssue": { - "limit": 9 + "limit": 0 }, "reportOptionalCall": { - "limit": 7 + "limit": 0 }, "reportOptionalIterable": { - "limit": 6 + "limit": 0 }, "reportOptionalMemberAccess": { - "limit": 1086 + "limit": 1085 }, "reportOptionalOperand": { - "limit": 6 + "limit": 0 }, "reportOptionalSubscript": { - "limit": 17 + "limit": 0 }, "reportPossiblyUnboundVariable": { - "limit": 78 + "limit": 77 }, "reportPrivateUsage": { "limit": 2438 @@ -90,28 +90,28 @@ "limit": 12 }, "reportReturnType": { - "limit": 226 + "limit": 225 }, "reportTypedDictNotRequiredAccess": { - "limit": 30 + "limit": 27 }, "reportUndefinedVariable": { - "limit": 5 + "limit": 0 }, "reportUnknownArgumentType": { - "limit": 45905 + "limit": 45894 }, "reportUnknownLambdaType": { "limit": 113 }, "reportUnknownMemberType": { - "limit": 40556 + "limit": 40539 }, "reportUnknownParameterType": { - "limit": 20418 + "limit": 20403 }, "reportUnknownVariableType": { - "limit": 32168 + "limit": 32141 }, "reportUnnecessaryCast": { "limit": 177 @@ -123,7 +123,7 @@ "limit": 7 }, "reportUnnecessaryIsInstance": { - "limit": 1212 + "limit": 1209 }, "reportUntypedBaseClass": { "limit": 165 @@ -141,6 +141,6 @@ "limit": 1005 }, "reportUnusedVariable": { - "limit": 1298 + "limit": 1297 } } diff --git a/codecov.yaml b/codecov.yaml index f5acdd39136..bc0b3604329 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -15,6 +15,16 @@ ignore: flag_management: default_rules: carryforward: true + # Dead flags no CI job uploads anymore: their carried-forward sessions were + # measured against old revisions, and the stale line maps mark comment lines + # of since-edited files as missed, sinking patch coverage on unrelated PRs. + individual_flags: + - name: proxy-mgmt-behavior + carryforward: false + - name: security + carryforward: false + - name: proxy-db-schema-migration + carryforward: false component_management: individual_components: diff --git a/deploy/azure_resource_manager/azure_marketplace.zip b/deploy/azure_resource_manager/azure_marketplace.zip deleted file mode 100644 index 34751258637..00000000000 Binary files a/deploy/azure_resource_manager/azure_marketplace.zip and /dev/null differ diff --git a/deploy/azure_resource_manager/azure_marketplace/createUiDefinition.json b/deploy/azure_resource_manager/azure_marketplace/createUiDefinition.json deleted file mode 100644 index 4eba73bdba4..00000000000 --- a/deploy/azure_resource_manager/azure_marketplace/createUiDefinition.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/0.1.2-preview/CreateUIDefinition.MultiVm.json#", - "handler": "Microsoft.Azure.CreateUIDef", - "version": "0.1.2-preview", - "parameters": { - "config": { - "isWizard": false, - "basics": { } - }, - "basics": [ ], - "steps": [ ], - "outputs": { }, - "resourceTypes": [ ] - } -} \ No newline at end of file diff --git a/deploy/azure_resource_manager/azure_marketplace/mainTemplate.json b/deploy/azure_resource_manager/azure_marketplace/mainTemplate.json deleted file mode 100644 index 114e855bf54..00000000000 --- a/deploy/azure_resource_manager/azure_marketplace/mainTemplate.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "imageName": { - "type": "string", - "defaultValue": "ghcr.io/berriai/litellm:main-latest" - }, - "containerName": { - "type": "string", - "defaultValue": "litellm-container" - }, - "dnsLabelName": { - "type": "string", - "defaultValue": "litellm" - }, - "portNumber": { - "type": "int", - "defaultValue": 4000 - } - }, - "resources": [ - { - "type": "Microsoft.ContainerInstance/containerGroups", - "apiVersion": "2021-03-01", - "name": "[parameters('containerName')]", - "location": "[resourceGroup().location]", - "properties": { - "containers": [ - { - "name": "[parameters('containerName')]", - "properties": { - "image": "[parameters('imageName')]", - "resources": { - "requests": { - "cpu": 1, - "memoryInGB": 2 - } - }, - "ports": [ - { - "port": "[parameters('portNumber')]" - } - ] - } - } - ], - "osType": "Linux", - "restartPolicy": "Always", - "ipAddress": { - "type": "Public", - "ports": [ - { - "protocol": "tcp", - "port": "[parameters('portNumber')]" - } - ], - "dnsNameLabel": "[parameters('dnsLabelName')]" - } - } - } - ] - } \ No newline at end of file diff --git a/deploy/azure_resource_manager/main.bicep b/deploy/azure_resource_manager/main.bicep deleted file mode 100644 index b104cefe1e1..00000000000 --- a/deploy/azure_resource_manager/main.bicep +++ /dev/null @@ -1,42 +0,0 @@ -param imageName string = 'ghcr.io/berriai/litellm:main-latest' -param containerName string = 'litellm-container' -param dnsLabelName string = 'litellm' -param portNumber int = 4000 - -resource containerGroupName 'Microsoft.ContainerInstance/containerGroups@2021-03-01' = { - name: containerName - location: resourceGroup().location - properties: { - containers: [ - { - name: containerName - properties: { - image: imageName - resources: { - requests: { - cpu: 1 - memoryInGB: 2 - } - } - ports: [ - { - port: portNumber - } - ] - } - } - ] - osType: 'Linux' - restartPolicy: 'Always' - ipAddress: { - type: 'Public' - ports: [ - { - protocol: 'tcp' - port: portNumber - } - ] - dnsNameLabel: dnsLabelName - } - } -} diff --git a/deploy/charts/litellm-helm/templates/configmap-litellm.yaml b/deploy/charts/litellm-helm/templates/configmap-litellm.yaml deleted file mode 100644 index acbe4e3a4b5..00000000000 --- a/deploy/charts/litellm-helm/templates/configmap-litellm.yaml +++ /dev/null @@ -1,9 +0,0 @@ -{{- if .Values.proxyConfigMap.create }} -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "litellm.fullname" . }}-config -data: - config.yaml: | -{{ .Values.proxy_config | toYaml | indent 6 }} -{{- end }} diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index b3af953511d..4564ee403fe 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -1,10 +1,10 @@ # syntax=docker/dockerfile:1.7 # Base image for building -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370 +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f # Runtime image -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370 +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a # Pinned by digest like the other base images; bump explicitly on Node upgrades. ARG UI_BUILD_IMAGE=node:20.18-alpine3.20@sha256:3488b10bf958af7125a176419d2d8a9937d895bf124012aae811651988d2ffe6 diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index c24cb9008f0..1883e87be60 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1.7 # Base images -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370 -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370 +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f ARG PROXY_EXTRAS_SOURCE=published ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a # Pinned by digest like the other base images; bump explicitly on Node upgrades. diff --git a/docker/build_admin_ui.sh b/docker/build_admin_ui.sh index efb2bac3535..68acdd78e3e 100755 --- a/docker/build_admin_ui.sh +++ b/docker/build_admin_ui.sh @@ -57,8 +57,6 @@ source ~/.nvm/nvm.sh nvm install v18.17.0 nvm use v18.17.0 -# copy _enterprise.json from this directory to /ui/litellm-dashboard, and rename it to ui_colors.json -cp enterprise/enterprise_ui/enterprise_colors.json ui/litellm-dashboard/ui_colors.json # cd in to /ui/litellm-dashboard cd ui/litellm-dashboard diff --git a/enterprise/README.md b/enterprise/README.md index f5eb5078e81..c708dad5a06 100644 --- a/enterprise/README.md +++ b/enterprise/README.md @@ -6,4 +6,4 @@ Code in this folder is licensed under a commercial license. Please review the [L 👉 **Using in an Enterprise / Need specific features ?** Meet with us [here](https://enterprise.litellm.ai/demo?month=2024-02) -See all Enterprise Features here 👉 [Docs](https://docs.litellm.ai/docs/proxy/enterprise) +See all Enterprise Features here 👉 [Docs](https://docs.litellm.ai/docs/enterprise) diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py index 9d15f45079f..e7898cac565 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py @@ -477,9 +477,12 @@ class BaseEmailLogger(CustomLogger): _id = user_info.token or user_info.user_id or "default_id" _cache_key = f"email_budget_alerts:soft_budget_crossed:{_id}" - # Check if we've already sent this alert - result = await _cache.async_get_cache(key=_cache_key) - if result is None: + send_count = await _cache.async_increment_cache( + key=_cache_key, + value=1, + ttl=EMAIL_BUDGET_ALERT_TTL, + ) + if send_count is None or send_count <= 1: # Create WebhookEvent for soft budget alert event_message = f"Soft Budget Crossed - Total Soft Budget: ${user_info.soft_budget}" webhook_event = WebhookEvent( @@ -508,18 +511,12 @@ class BaseEmailLogger(CustomLogger): await self.send_team_soft_budget_alert_email(webhook_event) else: await self.send_soft_budget_alert_email(webhook_event) - - # Cache the alert to prevent duplicate sends - await _cache.async_set_cache( - key=_cache_key, - value="SENT", - ttl=EMAIL_BUDGET_ALERT_TTL, - ) except Exception as e: verbose_proxy_logger.error( f"Error sending soft budget alert email: {e}", exc_info=True, ) + await self._release_budget_alert_claim(_cache, _cache_key) return # For max_budget_alert, check if we've already sent an alert @@ -545,9 +542,12 @@ class BaseEmailLogger(CustomLogger): _id = user_info.token or user_info.user_id or "default_id" _cache_key = f"email_budget_alerts:max_budget_alert:{_id}" - # Check if we've already sent this alert - result = await _cache.async_get_cache(key=_cache_key) - if result is None: + send_count = await _cache.async_increment_cache( + key=_cache_key, + value=1, + ttl=EMAIL_BUDGET_ALERT_TTL, + ) + if send_count is None or send_count <= 1: # Calculate percentage percentage = int( EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE * 100 @@ -576,18 +576,12 @@ class BaseEmailLogger(CustomLogger): try: await self.send_max_budget_alert_email(webhook_event) - - # Cache the alert to prevent duplicate sends - await _cache.async_set_cache( - key=_cache_key, - value="SENT", - ttl=EMAIL_BUDGET_ALERT_TTL, - ) except Exception as e: verbose_proxy_logger.error( f"Error sending max budget alert email: {e}", exc_info=True, ) + await self._release_budget_alert_claim(_cache, _cache_key) return async def _handle_multi_threshold_max_budget_alert( @@ -617,10 +611,6 @@ class BaseEmailLogger(CustomLogger): f"email_budget_alerts:max_budget_alert:{threshold_pct}:{_id}" ) - result = await _cache.async_get_cache(key=_cache_key) - if result is not None: - continue - # Parse emails + auto-include owner emails = _parse_email_list(raw_emails) if user_info.user_email: @@ -634,6 +624,14 @@ class BaseEmailLogger(CustomLogger): continue recipient_emails = list(set(emails)) + send_count = await _cache.async_increment_cache( + key=_cache_key, + value=1, + ttl=EMAIL_BUDGET_ALERT_TTL, + ) + if send_count is not None and send_count > 1: + continue + event_message = f"Max Budget Alert - {threshold_pct}% of Maximum Budget Reached" webhook_event = WebhookEvent( event="max_budget_alert", @@ -660,16 +658,21 @@ class BaseEmailLogger(CustomLogger): threshold_pct=threshold_pct, recipient_emails=recipient_emails, ) - await _cache.async_set_cache( - key=_cache_key, - value="SENT", - ttl=EMAIL_BUDGET_ALERT_TTL, - ) except Exception as e: verbose_proxy_logger.error( f"Error sending multi-threshold max budget alert email for {threshold_pct}%: {e}", exc_info=True, ) + await self._release_budget_alert_claim(_cache, _cache_key) + + async def _release_budget_alert_claim(self, cache: DualCache, cache_key: str) -> None: + try: + await cache.async_delete_cache(key=cache_key) + except Exception: + verbose_proxy_logger.debug( + "Failed to release budget alert claim for %s; it expires with the TTL", + cache_key, + ) async def _get_email_params( self, @@ -916,9 +919,9 @@ class BaseEmailLogger(CustomLogger): """ Construct invitation link for the user - # http://localhost:4000/ui?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b + # http://localhost:4000/ui/onboarding?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b """ - return f"{base_url}/ui?invitation_id={invitation_id}" + return f"{base_url}/ui/onboarding?invitation_id={invitation_id}" async def send_email( self, diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index 831a23ff3cd..f209ab54f64 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from litellm.proxy._types import LiteLLM_ManagedObjectTable from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.router import Router + from litellm.types.utils import LiteLLMBatch CHECK_BATCH_COST_USER_AGENT = "LiteLLM Proxy/CheckBatchCost" @@ -28,7 +29,7 @@ class CheckBatchCost: proxy_logging_obj: "ProxyLogging", prisma_client: "PrismaClient", llm_router: "Router", - track_unmanaged_vertex_batch_cost: bool = False, + track_unmanaged_batch_cost: bool = False, ): from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.router import Router @@ -36,7 +37,7 @@ class CheckBatchCost: self.proxy_logging_obj: ProxyLogging = proxy_logging_obj self.prisma_client: PrismaClient = prisma_client self.llm_router: Router = llm_router - self._track_unmanaged_vertex_batch_cost = track_unmanaged_vertex_batch_cost + self._track_unmanaged_batch_cost = track_unmanaged_batch_cost # Cached after the first poll cycle. Once we know the column is absent we skip # the guaranteed-failing primary query on every subsequent cycle. self._has_batch_processed_column: bool = True @@ -117,11 +118,11 @@ class CheckBatchCost: Resolve (model_id, batch_id) for a managed-object row, where model_id is a router deployment id and batch_id is the raw provider batch id. - Managed batches encode both in a base64 unified id. Unmanaged Vertex batches, created with - a raw gs:// input_file_id, store the raw provider job id as unified_object_id; when - track_unmanaged_vertex_batch_cost is enabled the model is derived from the gs:// path and - mapped to a configured vertex_ai deployment. Returns None (recording a metric) when the row - can't be routed. + Managed batches encode both in a base64 unified id. Unmanaged batches (created outside + LiteLLM's own /v1/batches with a raw input_file_id) store the raw provider job id as + unified_object_id instead; when track_unmanaged_batch_cost is enabled the model is derived + from the provider-specific input_file_id layout (Vertex gs:// or Bedrock s3://) and mapped + to a matching deployment. Returns None (recording a metric) when the row can't be routed. """ from litellm.proxy.openai_files_endpoints.common_utils import ( _is_base64_encoded_unified_file_id, @@ -141,8 +142,43 @@ class CheckBatchCost: return None return model_id, get_batch_id_from_unified_batch_id(decoded) - if self._track_unmanaged_vertex_batch_cost: - return self._resolve_unmanaged_vertex_routing(job, prom_logger) + if self._track_unmanaged_batch_cost: + from litellm.llms.bedrock.batches.transformation import ( + BedrockBatchesConfig, + ) + from litellm.llms.vertex_ai.batches.transformation import ( + VertexAIBatchTransformation, + ) + + input_file_id = self._get_input_file_id(job) + if VertexAIBatchTransformation.is_unmanaged_gcs_batch_input_file_id( + input_file_id + ): + assert input_file_id is not None # narrowed by is_unmanaged_gcs_batch_input_file_id + return self._resolve_unmanaged_provider_routing( + job=job, + prom_logger=prom_logger, + llm_provider="vertex_ai", + bare_model_name=VertexAIBatchTransformation.get_bare_model_name_from_gcs_file( + input_file_id + ), + ) + if BedrockBatchesConfig.is_unmanaged_s3_batch_input_file_id(input_file_id): + assert input_file_id is not None # narrowed by is_unmanaged_s3_batch_input_file_id + return self._resolve_unmanaged_provider_routing( + job=job, + prom_logger=prom_logger, + llm_provider="bedrock", + bare_model_name=BedrockBatchesConfig.get_bare_model_name_from_s3_file( + input_file_id + ), + ) + verbose_proxy_logger.info( + f"Skipping job {unified_object_id}: not a recognized unmanaged batch " + "(no gs:// or s3:// input_file_id with an embedded model)" + ) + self._record_error(prom_logger, "invalid_unified_id") + return None verbose_proxy_logger.info( f"Skipping job {unified_object_id} because it is not a valid unified object id" @@ -150,36 +186,17 @@ class CheckBatchCost: self._record_error(prom_logger, "invalid_unified_id") return None - def _resolve_unmanaged_vertex_routing( + def _resolve_unmanaged_provider_routing( self, job: "LiteLLM_ManagedObjectTable", prom_logger: Optional["PrometheusLogger"], + llm_provider: str, + bare_model_name: str, ) -> Optional[Tuple[str, str]]: - from litellm.llms.vertex_ai.batches.transformation import ( - VertexAIBatchTransformation, - ) - - input_file_id = self._get_input_file_id(job) - if not VertexAIBatchTransformation.is_unmanaged_gcs_batch_input_file_id( - input_file_id - ): - verbose_proxy_logger.info( - f"Skipping job {job.unified_object_id}: not an unmanaged vertex batch " - "(no gs:// input_file_id with a publishers/ model path)" - ) - self._record_error(prom_logger, "invalid_unified_id") - return None - assert input_file_id is not None # narrowed by is_unmanaged_gcs_batch_input_file_id - - bare_model_name = VertexAIBatchTransformation.get_bare_model_name_from_gcs_file( - input_file_id - ) - deployment_id = self._get_vertex_ai_deployment_id_for_bare_model( - bare_model_name - ) + deployment_id = self._get_deployment_id_for_bare_model(bare_model_name, llm_provider) if deployment_id is None: verbose_proxy_logger.info( - f"Skipping unmanaged vertex batch {job.unified_object_id}: no vertex_ai " + f"Skipping unmanaged {llm_provider} batch {job.unified_object_id}: no {llm_provider} " f"deployment configured for model {bare_model_name}" ) self._record_error(prom_logger, "unmanaged_no_matching_deployment") @@ -187,22 +204,22 @@ class CheckBatchCost: return deployment_id, job.unified_object_id - def _get_vertex_ai_deployment_id_for_bare_model( - self, bare_model_name: str + def _get_deployment_id_for_bare_model( + self, bare_model_name: str, llm_provider: str ) -> Optional[str]: model_group = self.llm_router.resolve_model_name_from_model_id(bare_model_name) deployment_id = ( - self._get_vertex_ai_deployment_id(model_group) if model_group else None + self._get_deployment_id_for_provider(model_group, llm_provider) if model_group else None ) if deployment_id is not None: return deployment_id - return self._get_vertex_ai_deployment_id_from_matching_deployments( - bare_model_name + return self._get_deployment_id_from_matching_deployments( + bare_model_name, llm_provider ) - def _get_vertex_ai_deployment_id_from_matching_deployments( - self, bare_model_name: str + def _get_deployment_id_from_matching_deployments( + self, bare_model_name: str, llm_provider: str ) -> Optional[str]: from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider @@ -214,13 +231,13 @@ class CheckBatchCost: if not self._is_bare_model_match(actual_model, bare_model_name): continue try: - _, llm_provider, _, _ = get_llm_provider( + _, deployment_llm_provider, _, _ = get_llm_provider( model=actual_model, custom_llm_provider=litellm_params.get("custom_llm_provider"), ) except Exception: continue - if llm_provider != "vertex_ai": + if deployment_llm_provider != llm_provider: continue model_info = deployment.get("model_info") or {} deployment_id = model_info.get("id") @@ -230,15 +247,21 @@ class CheckBatchCost: @staticmethod def _is_bare_model_match(actual_model: str, bare_model_name: str) -> bool: + # Bedrock model ids may have ":" replaced with "-" in the S3 object key (see + # BedrockBatchesConfig.get_bare_model_name_from_s3_file), so normalize both sides; + # a no-op for providers like vertex_ai whose model ids never contain a colon. + normalized_actual = actual_model.replace(":", "-") + normalized_bare = bare_model_name.replace(":", "-") return ( - actual_model == bare_model_name - or actual_model.endswith(f"/{bare_model_name}") - or actual_model.endswith(f":{bare_model_name}") + normalized_actual == normalized_bare + or normalized_actual.endswith(f"/{normalized_bare}") ) - def _get_vertex_ai_deployment_id(self, model_group: str) -> Optional[str]: + def _get_deployment_id_for_provider( + self, model_group: str, llm_provider: str + ) -> Optional[str]: """ - Returns the first deployment id for `model_group` whose provider is vertex_ai, + Returns the first deployment id for `model_group` whose provider is `llm_provider`, skipping deployments from other providers that happen to share the model group name. """ from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider @@ -248,13 +271,13 @@ class CheckBatchCost: if deployment_info is None: continue try: - _, llm_provider, _, _ = get_llm_provider( + _, deployment_llm_provider, _, _ = get_llm_provider( model=deployment_info.litellm_params.model, custom_llm_provider=deployment_info.litellm_params.custom_llm_provider, ) except Exception: continue - if llm_provider == "vertex_ai": + if deployment_llm_provider == llm_provider: return deployment_id return None @@ -277,13 +300,20 @@ class CheckBatchCost: except Exception: return None - async def check_batch_cost(self): + async def _track_completed_batch_cost( + self, + job: "LiteLLM_ManagedObjectTable", + response: "LiteLLMBatch", + model_id: str, + batch_id: str, + prom_logger: Optional["PrometheusLogger"], + ) -> Optional[Tuple[Optional[str], Optional[str]]]: """ - Check if the batch JOB has been tracked. - - get all status="validating" and file_purpose="batch" jobs - - check if batch is now complete - - if not, return False - - if so, return True + Fetch a completed batch's results, compute cost/usage, and emit the + aretrieve_batch spend log. Returns (model_name, llm_provider) on + success, None when the job can't be routed to a deployment. Raises on + results-fetch or cost-computation failures so the caller can leave the + job unprocessed and retry it on a later poll. """ from litellm.batches.batch_utils import ( _get_file_content_as_dictionary, @@ -296,6 +326,184 @@ class CheckBatchCost: _is_base64_encoded_unified_file_id, ) + verbose_proxy_logger.info( + f"Batch ID: {batch_id} is complete, tracking cost and usage" + ) + + # aretrieve_batch is called with the raw provider batch ID, so response.id + # is the raw provider value (e.g. "batch_20260223-0518.234"). We need the + # unified base64 ID in the S3 log so downstream consumers can correlate it + # back to the batch they submitted via the proxy. + # + # CheckBatchCost builds its own LiteLLMLogging object (logging_obj below) and + # calls async_success_handler(result=response) directly. That handler calls + # _build_standard_logging_payload(response, ...) which reads response.id at + # that point — so setting response.id here is sufficient. + # + # The HTTP endpoint does this substitution via the managed files hook + # (async_post_call_success_hook). CheckBatchCost bypasses that hook entirely, + # so we do it explicitly here. + response.id = job.unified_object_id + + # This background job runs as default_user_id, so going through the HTTP endpoint + # would trigger check_managed_file_id_access and get 403. Instead, extract the raw + # provider file ID and call afile_content directly with deployment credentials. + raw_output_file_id = response.output_file_id + decoded = _is_base64_encoded_unified_file_id(raw_output_file_id) + if decoded: + try: + raw_output_file_id = decoded.split("llm_output_file_id,")[1].split(";")[0] + except (IndexError, AttributeError): + pass + + credentials = self.llm_router.get_deployment_credentials_with_provider(model_id) or {} + _file_content = await afile_content( + file_id=raw_output_file_id, + **credentials, + ) + + # Access content - handle both direct attribute and method call + if hasattr(_file_content, 'content'): + content_bytes = _file_content.content # type: ignore[union-attr] + elif hasattr(_file_content, 'read'): + content_bytes = await _file_content.read() # type: ignore[misc] + else: + content_bytes = _file_content # type: ignore[assignment] + + file_content_as_dict = _get_file_content_as_dictionary( + content_bytes # type: ignore[arg-type] + ) + + # Record output file size + if prom_logger and content_bytes: + try: + prom_logger.record_managed_file_size( + size_bytes=len(content_bytes), # type: ignore + purpose="batch", + file_type="output", + model=model_id, + ) + except Exception: + pass + + deployment_info = self.llm_router.get_deployment(model_id=model_id) + if deployment_info is None: + verbose_proxy_logger.info( + f"Skipping job {job.unified_object_id} because it is not a valid deployment info" + ) + self._record_error(prom_logger, "deployment_not_found") + return None + custom_llm_provider = deployment_info.litellm_params.custom_llm_provider + litellm_model_name = deployment_info.litellm_params.model + + model_name, llm_provider, _, _ = get_llm_provider( + model=litellm_model_name, + custom_llm_provider=custom_llm_provider, + ) + + # CheckBatchCost bypasses async_post_call_success_hook, so convert raw + # output/error file IDs to managed base64 IDs before the DB write here. + managed_files_hook = self.proxy_logging_obj.get_proxy_hook("managed_files") + if managed_files_hook is not None: + from litellm.proxy._types import UserAPIKeyAuth + _minimal_auth = UserAPIKeyAuth( + user_id=job.created_by or "default-user-id", + team_id=getattr(job, "team_id", None), + ) + for _file_attr in ["output_file_id", "error_file_id"]: + _raw_file_id = getattr(response, _file_attr, None) + if _raw_file_id and not _is_base64_encoded_unified_file_id(_raw_file_id): + try: + _unified_file_id = managed_files_hook.get_unified_output_file_id( + output_file_id=_raw_file_id, + model_id=model_id, + model_name=str(model_name) if model_name else deployment_info.model_name or None, + ) + await managed_files_hook.store_unified_file_id( + file_id=_unified_file_id, + file_object=None, + litellm_parent_otel_span=None, + model_mappings={model_id: _raw_file_id}, + user_api_key_dict=_minimal_auth, + ) + setattr(response, _file_attr, _unified_file_id) + verbose_proxy_logger.info( + f"CheckBatchCost: converted {_file_attr} " + f"{_raw_file_id!r} -> managed ID for batch {batch_id}" + ) + except Exception as _e: + verbose_proxy_logger.warning( + f"CheckBatchCost: failed to create managed file ID for " + f"{_file_attr}={_raw_file_id!r}: {_e}" + ) + + # Pass deployment model_info so custom batch pricing + # (input_cost_per_token_batches etc.) is used for cost calc + deployment_model_info = deployment_info.model_info.model_dump() if deployment_info.model_info else {} + batch_cost, batch_usage, batch_models = ( + await calculate_batch_cost_and_usage( + file_content_dictionary=file_content_as_dict, + custom_llm_provider=llm_provider, # type: ignore + model_name=model_name, + model_info=deployment_model_info, # type: ignore[arg-type] + ) + ) + logging_obj = LiteLLMLogging( + model=batch_models[0], + messages=[{"role": "user", "content": ""}], + stream=False, + call_type="aretrieve_batch", + start_time=datetime.now(), + litellm_call_id=str(uuid.uuid4()), + function_id=str(uuid.uuid4()), + ) + + creator_user_id = job.created_by + user_info = await self._get_user_info(batch_id, job.created_by) + + logging_obj.update_environment_variables( + litellm_params={ + # set the user-agent header so that S3 callback consumers can easily identify CheckBatchCost callbacks + "proxy_server_request": { + "headers": { + "user-agent": CHECK_BATCH_COST_USER_AGENT, + } + }, + "metadata": { + "user_api_key_user_id": creator_user_id, + **user_info, + }, + }, + optional_params={}, + ) + + await logging_obj.async_success_handler( + result=response, + batch_cost=batch_cost, + batch_usage=batch_usage, + batch_models=batch_models, + ) + + # Record batch duration (completed_at - created_at) + if prom_logger and response.completed_at and response.created_at: + duration_seconds = float(response.completed_at - response.created_at) + if duration_seconds >= 0: + prom_logger.record_managed_batch_duration( + duration_seconds=duration_seconds, + model=model_name, + api_provider=str(llm_provider) if llm_provider else None, + ) + + return model_name, str(llm_provider) if llm_provider else None + + async def check_batch_cost(self): + """ + Check if the batch JOB has been tracked. + - get all status="validating" and file_purpose="batch" jobs + - check if batch is now complete + - if not, return False + - if so, return True + """ try: from litellm.integrations.prometheus import PrometheusLogger prom_logger = PrometheusLogger.get_instance() @@ -381,177 +589,26 @@ class CheckBatchCost: response.status == "completed" and response.output_file_id is not None ): - verbose_proxy_logger.info( - f"Batch ID: {batch_id} is complete, tracking cost and usage" - ) - - # aretrieve_batch is called with the raw provider batch ID, so response.id - # is the raw provider value (e.g. "batch_20260223-0518.234"). We need the - # unified base64 ID in the S3 log so downstream consumers can correlate it - # back to the batch they submitted via the proxy. - # - # CheckBatchCost builds its own LiteLLMLogging object (logging_obj below) and - # calls async_success_handler(result=response) directly. That handler calls - # _build_standard_logging_payload(response, ...) which reads response.id at - # that point — so setting response.id here is sufficient. - # - # The HTTP endpoint does this substitution via the managed files hook - # (async_post_call_success_hook). CheckBatchCost bypasses that hook entirely, - # so we do it explicitly here. - response.id = job.unified_object_id - - # This background job runs as default_user_id, so going through the HTTP endpoint - # would trigger check_managed_file_id_access and get 403. Instead, extract the raw - # provider file ID and call afile_content directly with deployment credentials. - raw_output_file_id = response.output_file_id - decoded = _is_base64_encoded_unified_file_id(raw_output_file_id) - if decoded: - try: - raw_output_file_id = decoded.split("llm_output_file_id,")[1].split(";")[0] - except (IndexError, AttributeError): - pass - - credentials = self.llm_router.get_deployment_credentials_with_provider(model_id) or {} - _file_content = await afile_content( - file_id=raw_output_file_id, - **credentials, - ) - - # Access content - handle both direct attribute and method call - if hasattr(_file_content, 'content'): - content_bytes = _file_content.content # type: ignore[union-attr] - elif hasattr(_file_content, 'read'): - content_bytes = await _file_content.read() # type: ignore[misc] - else: - content_bytes = _file_content # type: ignore[assignment] - - file_content_as_dict = _get_file_content_as_dictionary( - content_bytes # type: ignore[arg-type] - ) - - # Record output file size - if prom_logger and content_bytes: - try: - prom_logger.record_managed_file_size( - size_bytes=len(content_bytes), # type: ignore - purpose="batch", - file_type="output", - model=model_id, - ) - except Exception: - pass - - deployment_info = self.llm_router.get_deployment(model_id=model_id) - if deployment_info is None: - verbose_proxy_logger.info( - f"Skipping job {job.unified_object_id} because it is not a valid deployment info" + try: + tracked = await self._track_completed_batch_cost( + job=job, + response=response, + model_id=model_id, + batch_id=batch_id, + prom_logger=prom_logger, ) - if prom_logger: - prom_logger.record_check_batch_cost_error("deployment_not_found") + except Exception as tracking_err: + verbose_proxy_logger.error( + f"CheckBatchCost: failed to track cost for batch {batch_id} " + f"(job {job.id}); leaving it unprocessed so the next poll retries: {tracking_err}" + ) + self._record_error(prom_logger, "cost_tracking_error") + continue + if tracked is None: continue - custom_llm_provider = deployment_info.litellm_params.custom_llm_provider - litellm_model_name = deployment_info.litellm_params.model - - model_name, llm_provider, _, _ = get_llm_provider( - model=litellm_model_name, - custom_llm_provider=custom_llm_provider, - ) - - # CheckBatchCost bypasses async_post_call_success_hook, so convert raw - # output/error file IDs to managed base64 IDs before the DB write here. - managed_files_hook = self.proxy_logging_obj.get_proxy_hook("managed_files") - if managed_files_hook is not None: - from litellm.proxy._types import UserAPIKeyAuth - _minimal_auth = UserAPIKeyAuth( - user_id=job.created_by or "default-user-id", - team_id=getattr(job, "team_id", None), - ) - for _file_attr in ["output_file_id", "error_file_id"]: - _raw_file_id = getattr(response, _file_attr, None) - if _raw_file_id and not _is_base64_encoded_unified_file_id(_raw_file_id): - try: - _unified_file_id = managed_files_hook.get_unified_output_file_id( - output_file_id=_raw_file_id, - model_id=model_id, - model_name=str(model_name) if model_name else deployment_info.model_name or None, - ) - await managed_files_hook.store_unified_file_id( - file_id=_unified_file_id, - file_object=None, - litellm_parent_otel_span=None, - model_mappings={model_id: _raw_file_id}, - user_api_key_dict=_minimal_auth, - ) - setattr(response, _file_attr, _unified_file_id) - verbose_proxy_logger.info( - f"CheckBatchCost: converted {_file_attr} " - f"{_raw_file_id!r} -> managed ID for batch {batch_id}" - ) - except Exception as _e: - verbose_proxy_logger.warning( - f"CheckBatchCost: failed to create managed file ID for " - f"{_file_attr}={_raw_file_id!r}: {_e}" - ) - - # Pass deployment model_info so custom batch pricing - # (input_cost_per_token_batches etc.) is used for cost calc - deployment_model_info = deployment_info.model_info.model_dump() if deployment_info.model_info else {} - batch_cost, batch_usage, batch_models = ( - await calculate_batch_cost_and_usage( - file_content_dictionary=file_content_as_dict, - custom_llm_provider=llm_provider, # type: ignore - model_name=model_name, - model_info=deployment_model_info, # type: ignore[arg-type] - ) - ) - logging_obj = LiteLLMLogging( - model=batch_models[0], - messages=[{"role": "user", "content": ""}], - stream=False, - call_type="aretrieve_batch", - start_time=datetime.now(), - litellm_call_id=str(uuid.uuid4()), - function_id=str(uuid.uuid4()), - ) - - creator_user_id = job.created_by - user_info = await self._get_user_info(batch_id, job.created_by) - - logging_obj.update_environment_variables( - litellm_params={ - # set the user-agent header so that S3 callback consumers can easily identify CheckBatchCost callbacks - "proxy_server_request": { - "headers": { - "user-agent": CHECK_BATCH_COST_USER_AGENT, - } - }, - "metadata": { - "user_api_key_user_id": creator_user_id, - **user_info, - }, - }, - optional_params={}, - ) - - await logging_obj.async_success_handler( - result=response, - batch_cost=batch_cost, - batch_usage=batch_usage, - batch_models=batch_models, - ) - - # Record batch duration (completed_at - created_at) - if prom_logger and response.completed_at and response.created_at: - duration_seconds = float(response.completed_at - response.created_at) - if duration_seconds >= 0: - prom_logger.record_managed_batch_duration( - duration_seconds=duration_seconds, - model=model_name, - api_provider=str(llm_provider) if llm_provider else None, - ) # Track this job for the final metrics summary - processed_models.append((model_name, str(llm_provider) if llm_provider else None)) + processed_models.append(tracked) # mark the job as complete try: diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index f2ad04510a8..97571a4576d 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-enterprise" -version = "0.1.45" +version = "0.1.50" description = "Package for LiteLLM Enterprise features" readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.1.45" +version = "0.1.50" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-enterprise==", diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 716b2fa09d1..da2f2c9c1e0 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -1,5 +1,5 @@ -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370 -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370 +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a FROM $UV_IMAGE AS uvbin diff --git a/gateway/main.py b/gateway/main.py index 09d30f5da3f..61b885b27e4 100644 --- a/gateway/main.py +++ b/gateway/main.py @@ -25,17 +25,25 @@ DatabaseURLSettings.from_env().apply_to_env() from litellm.proxy.proxy_server import app -from gateway.routes.allowlist import GATEWAY_EXACT_PATHS, GATEWAY_PATH_PREFIXES +from gateway.routes.allowlist import ( + GATEWAY_EXACT_PATHS, + GATEWAY_MOUNT_PATHS, + GATEWAY_PATH_PREFIXES, +) def _is_gateway_route(route) -> bool: - """Keep the route on the gateway if its path is in the LLM data-plane surface.""" + """Keep the route on the gateway if its path is in the LLM data-plane surface. + + Prometheus registers /metrics as a Mount (``app.mount("/metrics", make_asgi_app())``), + so Mounts are matched against GATEWAY_MOUNT_PATHS instead of being dropped with + the UI static mounts. + """ path = getattr(route, "path", None) if path is None: return False if isinstance(route, Mount): - # Gateway never serves the static UI or its asset bundles. - return False + return path in GATEWAY_MOUNT_PATHS if path in GATEWAY_EXACT_PATHS: return True return any(path.startswith(prefix) for prefix in GATEWAY_PATH_PREFIXES) diff --git a/gateway/routes/allowlist.py b/gateway/routes/allowlist.py index 144bb4c473f..792a56a2cd8 100644 --- a/gateway/routes/allowlist.py +++ b/gateway/routes/allowlist.py @@ -106,7 +106,7 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = ( # Health & ops "/health", "/metrics", - "/watsonx" + "/watsonx", ) GATEWAY_EXACT_PATHS: frozenset[str] = frozenset( @@ -120,3 +120,9 @@ GATEWAY_EXACT_PATHS: frozenset[str] = frozenset( "/test", } ) + +GATEWAY_MOUNT_PATHS: frozenset[str] = frozenset( + { + "/metrics", + } +) diff --git a/deploy/charts/litellm-helm/.helmignore b/helm/litellm-helm/.helmignore similarity index 100% rename from deploy/charts/litellm-helm/.helmignore rename to helm/litellm-helm/.helmignore diff --git a/deploy/charts/litellm-helm/Chart.lock b/helm/litellm-helm/Chart.lock similarity index 100% rename from deploy/charts/litellm-helm/Chart.lock rename to helm/litellm-helm/Chart.lock diff --git a/deploy/charts/litellm-helm/Chart.yaml b/helm/litellm-helm/Chart.yaml similarity index 100% rename from deploy/charts/litellm-helm/Chart.yaml rename to helm/litellm-helm/Chart.yaml diff --git a/deploy/charts/litellm-helm/README.md b/helm/litellm-helm/README.md similarity index 100% rename from deploy/charts/litellm-helm/README.md rename to helm/litellm-helm/README.md diff --git a/deploy/charts/litellm-helm/charts/postgresql-14.3.1.tgz b/helm/litellm-helm/charts/postgresql-14.3.1.tgz similarity index 100% rename from deploy/charts/litellm-helm/charts/postgresql-14.3.1.tgz rename to helm/litellm-helm/charts/postgresql-14.3.1.tgz diff --git a/deploy/charts/litellm-helm/charts/redis-18.19.1.tgz b/helm/litellm-helm/charts/redis-18.19.1.tgz similarity index 100% rename from deploy/charts/litellm-helm/charts/redis-18.19.1.tgz rename to helm/litellm-helm/charts/redis-18.19.1.tgz diff --git a/deploy/charts/litellm-helm/ci/test-values.yaml b/helm/litellm-helm/ci/test-values.yaml similarity index 100% rename from deploy/charts/litellm-helm/ci/test-values.yaml rename to helm/litellm-helm/ci/test-values.yaml diff --git a/deploy/charts/litellm-helm/templates/NOTES.txt b/helm/litellm-helm/templates/NOTES.txt similarity index 100% rename from deploy/charts/litellm-helm/templates/NOTES.txt rename to helm/litellm-helm/templates/NOTES.txt diff --git a/deploy/charts/litellm-helm/templates/_helpers.tpl b/helm/litellm-helm/templates/_helpers.tpl similarity index 91% rename from deploy/charts/litellm-helm/templates/_helpers.tpl rename to helm/litellm-helm/templates/_helpers.tpl index 25b02dd5f37..469d52c03a7 100644 --- a/deploy/charts/litellm-helm/templates/_helpers.tpl +++ b/helm/litellm-helm/templates/_helpers.tpl @@ -76,10 +76,13 @@ so fall back to "default" (or an explicit override) to avoid a cyclic dependency {{- end }} {{/* -Get redis service name +Get redis service name. +The bundled Redis subchart only serves sentinel in "replication" architecture +(it rejects standalone + sentinel outright), and in that mode the sentinel +Service is named "-redis", not "-redis-master". */}} {{- define "litellm.redis.serviceName" -}} -{{- if and (eq .Values.redis.architecture "standalone") .Values.redis.sentinel.enabled -}} +{{- if .Values.redis.sentinel.enabled -}} {{- printf "%s-%s" .Release.Name (default "redis" .Values.redis.nameOverride | trunc 63 | trimSuffix "-") -}} {{- else -}} {{- printf "%s-%s-master" .Release.Name (default "redis" .Values.redis.nameOverride | trunc 63 | trimSuffix "-") -}} diff --git a/helm/litellm-helm/templates/configmap-litellm.yaml b/helm/litellm-helm/templates/configmap-litellm.yaml new file mode 100644 index 00000000000..03e4f620206 --- /dev/null +++ b/helm/litellm-helm/templates/configmap-litellm.yaml @@ -0,0 +1,22 @@ +{{- if .Values.proxyConfigMap.create }} +{{- $config := deepCopy .Values.proxy_config }} +{{- if and .Values.redis.enabled (dig "coordination" "enabled" true .Values.redis) }} +{{- $generalSettings := (get $config "general_settings") | default dict }} +{{- if not (hasKey $generalSettings "coordination_redis") }} +{{- $coordinationRedis := dict "host" "os.environ/REDIS_HOST" "port" "os.environ/REDIS_PORT" "password" "os.environ/REDIS_PASSWORD" }} +{{- if .Values.redis.sentinel.enabled }} +{{- $sentinelNode := list (include "litellm.redis.serviceName" .) (include "litellm.redis.port" . | int) }} +{{- $coordinationRedis = dict "sentinel_nodes" (list $sentinelNode) "service_name" (default "mymaster" .Values.redis.sentinel.masterSet) "password" "os.environ/REDIS_PASSWORD" }} +{{- end }} +{{- $_ := set $generalSettings "coordination_redis" $coordinationRedis }} +{{- $_ := set $config "general_settings" $generalSettings }} +{{- end }} +{{- end }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "litellm.fullname" . }}-config +data: + config.yaml: | +{{ $config | toYaml | indent 6 }} +{{- end }} diff --git a/deploy/charts/litellm-helm/templates/deployment.yaml b/helm/litellm-helm/templates/deployment.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/deployment.yaml rename to helm/litellm-helm/templates/deployment.yaml diff --git a/deploy/charts/litellm-helm/templates/extra-resources.yaml b/helm/litellm-helm/templates/extra-resources.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/extra-resources.yaml rename to helm/litellm-helm/templates/extra-resources.yaml diff --git a/deploy/charts/litellm-helm/templates/hpa.yaml b/helm/litellm-helm/templates/hpa.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/hpa.yaml rename to helm/litellm-helm/templates/hpa.yaml diff --git a/deploy/charts/litellm-helm/templates/ingress.yaml b/helm/litellm-helm/templates/ingress.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/ingress.yaml rename to helm/litellm-helm/templates/ingress.yaml diff --git a/deploy/charts/litellm-helm/templates/keda.yaml b/helm/litellm-helm/templates/keda.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/keda.yaml rename to helm/litellm-helm/templates/keda.yaml diff --git a/deploy/charts/litellm-helm/templates/migrations-job.yaml b/helm/litellm-helm/templates/migrations-job.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/migrations-job.yaml rename to helm/litellm-helm/templates/migrations-job.yaml diff --git a/deploy/charts/litellm-helm/templates/poddisruptionbudget.yaml b/helm/litellm-helm/templates/poddisruptionbudget.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/poddisruptionbudget.yaml rename to helm/litellm-helm/templates/poddisruptionbudget.yaml diff --git a/deploy/charts/litellm-helm/templates/secret-dbcredentials.yaml b/helm/litellm-helm/templates/secret-dbcredentials.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/secret-dbcredentials.yaml rename to helm/litellm-helm/templates/secret-dbcredentials.yaml diff --git a/deploy/charts/litellm-helm/templates/secret-masterkey.yaml b/helm/litellm-helm/templates/secret-masterkey.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/secret-masterkey.yaml rename to helm/litellm-helm/templates/secret-masterkey.yaml diff --git a/deploy/charts/litellm-helm/templates/service.yaml b/helm/litellm-helm/templates/service.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/service.yaml rename to helm/litellm-helm/templates/service.yaml diff --git a/deploy/charts/litellm-helm/templates/serviceaccount.yaml b/helm/litellm-helm/templates/serviceaccount.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/serviceaccount.yaml rename to helm/litellm-helm/templates/serviceaccount.yaml diff --git a/deploy/charts/litellm-helm/templates/servicemonitor.yaml b/helm/litellm-helm/templates/servicemonitor.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/servicemonitor.yaml rename to helm/litellm-helm/templates/servicemonitor.yaml diff --git a/deploy/charts/litellm-helm/templates/tests/test-connection.yaml b/helm/litellm-helm/templates/tests/test-connection.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/tests/test-connection.yaml rename to helm/litellm-helm/templates/tests/test-connection.yaml diff --git a/deploy/charts/litellm-helm/templates/tests/test-env-vars.yaml b/helm/litellm-helm/templates/tests/test-env-vars.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/tests/test-env-vars.yaml rename to helm/litellm-helm/templates/tests/test-env-vars.yaml diff --git a/deploy/charts/litellm-helm/templates/tests/test-servicemonitor.yaml b/helm/litellm-helm/templates/tests/test-servicemonitor.yaml similarity index 100% rename from deploy/charts/litellm-helm/templates/tests/test-servicemonitor.yaml rename to helm/litellm-helm/templates/tests/test-servicemonitor.yaml diff --git a/helm/litellm-helm/tests/coordination_redis_tests.yaml b/helm/litellm-helm/tests/coordination_redis_tests.yaml new file mode 100644 index 00000000000..0b58b1e6bc8 --- /dev/null +++ b/helm/litellm-helm/tests/coordination_redis_tests.yaml @@ -0,0 +1,143 @@ +suite: test coordination redis +templates: + - configmap-litellm.yaml + - deployment.yaml +tests: + - it: should not render coordination_redis when redis is disabled + template: configmap-litellm.yaml + set: + redis.enabled: false + asserts: + - notMatchRegex: + path: data["config.yaml"] + pattern: coordination_redis + + - it: should not emit redis env vars when redis is disabled + template: deployment.yaml + set: + redis.enabled: false + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_HOST + value: RELEASE-NAME-redis-master + any: true + + - it: should render coordination_redis pointing at the bundled redis when enabled + template: configmap-litellm.yaml + set: + redis.enabled: true + asserts: + - matchRegex: + path: data["config.yaml"] + pattern: "coordination_redis:\n host: os.environ/REDIS_HOST\n password: os.environ/REDIS_PASSWORD\n port: os.environ/REDIS_PORT\n" + - matchRegex: + path: data["config.yaml"] + pattern: "master_key: os.environ/PROXY_MASTER_KEY" + + - it: should emit redis env vars backing the coordination_redis os.environ refs + template: deployment.yaml + set: + redis.enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_HOST + value: RELEASE-NAME-redis-master + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_PORT + value: "6379" + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: RELEASE-NAME-redis + key: redis-password + + - it: should not render coordination_redis when coordination is opted out + template: configmap-litellm.yaml + set: + redis.enabled: true + redis.coordination.enabled: false + asserts: + - notMatchRegex: + path: data["config.yaml"] + pattern: coordination_redis + + - it: should keep emitting redis env vars when coordination is opted out + template: deployment.yaml + set: + redis.enabled: true + redis.coordination.enabled: false + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_HOST + value: RELEASE-NAME-redis-master + + - it: should not clobber a user supplied coordination_redis block + template: configmap-litellm.yaml + set: + redis.enabled: true + proxy_config.general_settings.coordination_redis: + url: os.environ/COORDINATION_REDIS_URL + asserts: + - matchRegex: + path: data["config.yaml"] + pattern: "coordination_redis:\n url: os.environ/COORDINATION_REDIS_URL\n" + - notMatchRegex: + path: data["config.yaml"] + pattern: "host: os.environ/REDIS_HOST" + + - it: should render sentinel_nodes and service_name in sentinel mode + template: configmap-litellm.yaml + set: + redis.enabled: true + redis.architecture: replication + redis.sentinel.enabled: true + asserts: + # The sentinel Service the redis subchart renders is "-redis", and a + # plain client cannot speak the sentinel protocol, so host/port must not appear + - matchRegex: + path: data["config.yaml"] + pattern: "coordination_redis:\n password: os.environ/REDIS_PASSWORD\n sentinel_nodes:\n - - RELEASE-NAME-redis\n - 26379\n service_name: mymaster\n" + - notMatchRegex: + path: data["config.yaml"] + pattern: "host: os.environ/REDIS_HOST" + + - it: should carry a custom sentinel masterSet into service_name + template: configmap-litellm.yaml + set: + redis.enabled: true + redis.architecture: replication + redis.sentinel.enabled: true + redis.sentinel.masterSet: litellm-master + asserts: + - matchRegex: + path: data["config.yaml"] + pattern: "service_name: litellm-master" + + - it: should point REDIS_HOST at the sentinel service in sentinel mode + template: deployment.yaml + set: + redis.enabled: true + redis.architecture: replication + redis.sentinel.enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_HOST + value: RELEASE-NAME-redis + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_PORT + value: "26379" diff --git a/deploy/charts/litellm-helm/tests/deployment_command_args_labels_tests.yaml b/helm/litellm-helm/tests/deployment_command_args_labels_tests.yaml similarity index 100% rename from deploy/charts/litellm-helm/tests/deployment_command_args_labels_tests.yaml rename to helm/litellm-helm/tests/deployment_command_args_labels_tests.yaml diff --git a/deploy/charts/litellm-helm/tests/deployment_tests.yaml b/helm/litellm-helm/tests/deployment_tests.yaml similarity index 100% rename from deploy/charts/litellm-helm/tests/deployment_tests.yaml rename to helm/litellm-helm/tests/deployment_tests.yaml diff --git a/deploy/charts/litellm-helm/tests/hpa_tests.yaml b/helm/litellm-helm/tests/hpa_tests.yaml similarity index 100% rename from deploy/charts/litellm-helm/tests/hpa_tests.yaml rename to helm/litellm-helm/tests/hpa_tests.yaml diff --git a/deploy/charts/litellm-helm/tests/ingress_tests.yaml b/helm/litellm-helm/tests/ingress_tests.yaml similarity index 100% rename from deploy/charts/litellm-helm/tests/ingress_tests.yaml rename to helm/litellm-helm/tests/ingress_tests.yaml diff --git a/deploy/charts/litellm-helm/tests/masterkey-secret_tests.yaml b/helm/litellm-helm/tests/masterkey-secret_tests.yaml similarity index 100% rename from deploy/charts/litellm-helm/tests/masterkey-secret_tests.yaml rename to helm/litellm-helm/tests/masterkey-secret_tests.yaml diff --git a/deploy/charts/litellm-helm/tests/migrations-job_tests.yaml b/helm/litellm-helm/tests/migrations-job_tests.yaml similarity index 100% rename from deploy/charts/litellm-helm/tests/migrations-job_tests.yaml rename to helm/litellm-helm/tests/migrations-job_tests.yaml diff --git a/deploy/charts/litellm-helm/tests/pdb_tests.yaml b/helm/litellm-helm/tests/pdb_tests.yaml similarity index 100% rename from deploy/charts/litellm-helm/tests/pdb_tests.yaml rename to helm/litellm-helm/tests/pdb_tests.yaml diff --git a/deploy/charts/litellm-helm/tests/service_tests.yaml b/helm/litellm-helm/tests/service_tests.yaml similarity index 100% rename from deploy/charts/litellm-helm/tests/service_tests.yaml rename to helm/litellm-helm/tests/service_tests.yaml diff --git a/deploy/charts/litellm-helm/values.yaml b/helm/litellm-helm/values.yaml similarity index 91% rename from deploy/charts/litellm-helm/values.yaml rename to helm/litellm-helm/values.yaml index 6e30a6af444..d3821a547e5 100644 --- a/deploy/charts/litellm-helm/values.yaml +++ b/helm/litellm-helm/values.yaml @@ -331,12 +331,28 @@ postgresql: # secretKeys: # userPasswordKey: password -# requires cache: true in config file -# either enable this or pass a secret for REDIS_HOST, REDIS_PORT, REDIS_PASSWORD or REDIS_URL -# with cache: true to use existing redis instance +# Redis is the proxy's coordination store: cross-pod tpm/rpm rate limits, spend +# tracking, and the pod lock manager. Enabling this deploys the bundled Redis +# subchart, wires REDIS_HOST / REDIS_PORT / REDIS_PASSWORD into the proxy, and +# renders a `general_settings.coordination_redis` block into the proxy config. +# +# To point at an existing Redis instead, leave `enabled: false` and pass a +# secret for REDIS_HOST, REDIS_PORT, REDIS_PASSWORD or REDIS_URL; the proxy +# falls back to those env vars for coordination. Set `cache: true` in the proxy +# config only if you also want LLM response caching, which is independent of +# coordination +# +# When `redis.sentinel.enabled` is set, the coordination block is rendered with +# `sentinel_nodes` and `service_name` (from `redis.sentinel.masterSet`) instead +# of host/port, because a plain Redis client cannot talk to the sentinel port redis: enabled: false architecture: standalone + coordination: + # Set to false to keep the bundled Redis for response caching only and leave + # `general_settings.coordination_redis` out of the rendered config. A + # `coordination_redis` block you define yourself in `proxy_config` always wins + enabled: true # Prisma migration job settings migrationJob: diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl index 4319907883e..7c281aa158b 100644 --- a/helm/litellm/templates/_helpers.tpl +++ b/helm/litellm/templates/_helpers.tpl @@ -213,6 +213,10 @@ harmless no-op for the Job and authoritative for the app pods. */}} - name: DISABLE_SCHEMA_UPDATE value: "true" +{{/* These feed the proxy's coordination Redis (cross-pod rate limits, spend + tracking, pod lock manager) via its REDIS_* env fallback. An explicit + `general_settings.coordination_redis` block in proxy_config takes + precedence over anything emitted here. */}} {{- if $root.Values.redis.host }} - name: REDIS_HOST value: {{ $root.Values.redis.host | quote }} @@ -226,10 +230,11 @@ harmless no-op for the Job and authoritative for the app pods. key: {{ $root.Values.redis.passwordSecret.passwordKey | default "password" }} {{- end }} {{- if $root.Values.redis.cluster }} -{{/* The proxy's Cache() reads REDIS_CLUSTER_NODES as JSON and constructs a - RedisClusterCache when it's set (litellm/caching/caching.py:169-192). - We seed with the single configured endpoint — the cluster client - discovers the remaining nodes from CLUSTER SLOTS at startup. */}} +{{/* The proxy falls back to REDIS_CLUSTER_NODES (JSON) to build a cluster-mode + coordination client when `general_settings.coordination_redis` is absent + and no plain-Redis response cache is configured. We seed with the single + configured endpoint; the cluster client discovers the remaining nodes from + CLUSTER SLOTS at startup. */}} - name: REDIS_CLUSTER_NODES value: {{ printf "[{\"host\":%q,\"port\":%v}]" $root.Values.redis.host (int $root.Values.redis.port) | quote }} {{- end }} diff --git a/helm/litellm/templates/backend/deployment.yaml b/helm/litellm/templates/backend/deployment.yaml index b355db43540..8b4552bf302 100644 --- a/helm/litellm/templates/backend/deployment.yaml +++ b/helm/litellm/templates/backend/deployment.yaml @@ -45,11 +45,16 @@ spec: value: /app/config/config.yaml {{- end }} {{- include "litellm.envFrom" .Values.backend | nindent 10 }} - {{- if .Values.gateway.config.create }} + {{- if or .Values.gateway.config.create .Values.backend.volumeMounts }} volumeMounts: + {{- if .Values.gateway.config.create }} - name: gateway-config mountPath: /app/config/config.yaml subPath: config.yaml + {{- end }} + {{- with .Values.backend.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} {{- end }} {{- with .Values.backend.livenessProbe }} livenessProbe: @@ -61,11 +66,16 @@ spec: {{- end }} resources: {{- toYaml .Values.backend.resources | nindent 12 }} - {{- if .Values.gateway.config.create }} + {{- if or .Values.gateway.config.create .Values.backend.volumes }} volumes: + {{- if .Values.gateway.config.create }} - name: gateway-config configMap: name: {{ include "litellm.gateway.fullname" . }}-config + {{- end }} + {{- with .Values.backend.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} {{- end }} {{- with .Values.backend.nodeSelector }} nodeSelector: diff --git a/helm/litellm/templates/gateway/deployment.yaml b/helm/litellm/templates/gateway/deployment.yaml index 05ea4052159..bd491b69e0f 100644 --- a/helm/litellm/templates/gateway/deployment.yaml +++ b/helm/litellm/templates/gateway/deployment.yaml @@ -47,11 +47,16 @@ spec: value: {{ .Values.gateway.numWorkers | quote }} {{- end }} {{- include "litellm.envFrom" .Values.gateway | nindent 10 }} - {{- if .Values.gateway.config.create }} + {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts }} volumeMounts: + {{- if .Values.gateway.config.create }} - name: gateway-config mountPath: /app/config/config.yaml subPath: config.yaml + {{- end }} + {{- with .Values.gateway.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} {{- end }} {{- with .Values.gateway.livenessProbe }} livenessProbe: @@ -63,11 +68,16 @@ spec: {{- end }} resources: {{- toYaml .Values.gateway.resources | nindent 12 }} - {{- if .Values.gateway.config.create }} + {{- if or .Values.gateway.config.create .Values.gateway.volumes }} volumes: + {{- if .Values.gateway.config.create }} - name: gateway-config configMap: name: {{ include "litellm.gateway.fullname" . }}-config + {{- end }} + {{- with .Values.gateway.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} {{- end }} {{- with .Values.gateway.nodeSelector }} nodeSelector: diff --git a/helm/litellm/templates/ui/deployment.yaml b/helm/litellm/templates/ui/deployment.yaml index b40b44cca53..79e9a3e43bb 100644 --- a/helm/litellm/templates/ui/deployment.yaml +++ b/helm/litellm/templates/ui/deployment.yaml @@ -46,6 +46,10 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} {{- include "litellm.envFrom" .Values.ui | nindent 10 }} + {{- with .Values.ui.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} {{- with .Values.ui.livenessProbe }} livenessProbe: {{- toYaml . | nindent 12 }} @@ -56,6 +60,10 @@ spec: {{- end }} resources: {{- toYaml .Values.ui.resources | nindent 12 }} + {{- with .Values.ui.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.ui.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/helm/litellm/tests/deployment_volumes_tests.yaml b/helm/litellm/tests/deployment_volumes_tests.yaml new file mode 100644 index 00000000000..3a64300b86c --- /dev/null +++ b/helm/litellm/tests/deployment_volumes_tests.yaml @@ -0,0 +1,172 @@ +suite: test deployment volumes and volumeMounts +templates: + - gateway/deployment.yaml + - gateway/configmap.yaml + - backend/deployment.yaml + - ui/deployment.yaml +values: + - ./values/required.yaml +tests: + - it: gateway renders only the config volume by default + template: gateway/deployment.yaml + asserts: + - equal: + path: spec.template.spec.volumes + value: + - name: gateway-config + configMap: + name: RELEASE-NAME-litellm-gateway-config + - equal: + path: spec.template.spec.containers[0].volumeMounts + value: + - name: gateway-config + mountPath: /app/config/config.yaml + subPath: config.yaml + + - it: gateway merges user volumes and volumeMounts with the config volume + template: gateway/deployment.yaml + set: + gateway.volumes: + - name: custom-callbacks + configMap: + name: custom-callbacks + gateway.volumeMounts: + - name: custom-callbacks + mountPath: /app/custom_callbacks.py + subPath: custom_callbacks.py + asserts: + - equal: + path: spec.template.spec.volumes[0].name + value: gateway-config + - equal: + path: spec.template.spec.volumes[1] + value: + name: custom-callbacks + configMap: + name: custom-callbacks + - equal: + path: spec.template.spec.containers[0].volumeMounts[0].name + value: gateway-config + - equal: + path: spec.template.spec.containers[0].volumeMounts[1] + value: + name: custom-callbacks + mountPath: /app/custom_callbacks.py + subPath: custom_callbacks.py + + - it: gateway renders user volumes even when config creation is disabled + template: gateway/deployment.yaml + set: + gateway.config.create: false + gateway.volumes: + - name: certs + secret: + secretName: tls-certs + gateway.volumeMounts: + - name: certs + mountPath: /etc/certs + readOnly: true + asserts: + - equal: + path: spec.template.spec.volumes + value: + - name: certs + secret: + secretName: tls-certs + - equal: + path: spec.template.spec.containers[0].volumeMounts + value: + - name: certs + mountPath: /etc/certs + readOnly: true + + - it: gateway omits volumes when config creation is disabled and no user volumes are set + template: gateway/deployment.yaml + set: + gateway.config.create: false + asserts: + - isNull: + path: spec.template.spec.volumes + - isNull: + path: spec.template.spec.containers[0].volumeMounts + + - it: backend merges user volumes and volumeMounts with the shared config volume + template: backend/deployment.yaml + set: + backend.volumes: + - name: sso-handler + configMap: + name: sso-handler + backend.volumeMounts: + - name: sso-handler + mountPath: /app/custom_sso.py + subPath: custom_sso.py + asserts: + - equal: + path: spec.template.spec.volumes[0].name + value: gateway-config + - equal: + path: spec.template.spec.volumes[1] + value: + name: sso-handler + configMap: + name: sso-handler + - equal: + path: spec.template.spec.containers[0].volumeMounts[1] + value: + name: sso-handler + mountPath: /app/custom_sso.py + subPath: custom_sso.py + + - it: backend renders user volumes even when config creation is disabled + template: backend/deployment.yaml + set: + gateway.config.create: false + backend.volumes: + - name: data + emptyDir: {} + backend.volumeMounts: + - name: data + mountPath: /data + asserts: + - equal: + path: spec.template.spec.volumes + value: + - name: data + emptyDir: {} + - equal: + path: spec.template.spec.containers[0].volumeMounts + value: + - name: data + mountPath: /data + + - it: ui renders no volumes by default + template: ui/deployment.yaml + asserts: + - isNull: + path: spec.template.spec.volumes + - isNull: + path: spec.template.spec.containers[0].volumeMounts + + - it: ui renders user volumes and volumeMounts + template: ui/deployment.yaml + set: + ui.volumes: + - name: nginx-config + configMap: + name: custom-nginx + ui.volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/conf.d + asserts: + - equal: + path: spec.template.spec.volumes + value: + - name: nginx-config + configMap: + name: custom-nginx + - equal: + path: spec.template.spec.containers[0].volumeMounts + value: + - name: nginx-config + mountPath: /etc/nginx/conf.d diff --git a/helm/litellm/tests/redis_env_tests.yaml b/helm/litellm/tests/redis_env_tests.yaml new file mode 100644 index 00000000000..684d7071b35 --- /dev/null +++ b/helm/litellm/tests/redis_env_tests.yaml @@ -0,0 +1,109 @@ +suite: test redis coordination env vars +templates: + - gateway/deployment.yaml + - gateway/configmap.yaml + - backend/deployment.yaml +values: + - ./values/required.yaml +tests: + - it: gateway omits redis env vars when no host is configured + template: gateway/deployment.yaml + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_HOST + value: redis.example.com + any: true + - notContains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_CLUSTER_NODES + any: true + + - it: gateway emits host, port and password when redis is configured + template: gateway/deployment.yaml + set: + redis.host: redis.example.com + redis.port: 6380 + redis.passwordSecret.name: redis-secret + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_HOST + value: redis.example.com + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_PORT + value: "6380" + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: redis-secret + key: password + + - it: backend emits the same redis env vars so both pods coordinate on one redis + template: backend/deployment.yaml + set: + redis.host: redis.example.com + redis.passwordSecret.name: redis-secret + redis.passwordSecret.passwordKey: redis-password + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_HOST + value: redis.example.com + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: redis-secret + key: redis-password + + - it: gateway omits REDIS_PASSWORD for an auth-less redis + template: gateway/deployment.yaml + set: + redis.host: redis.example.com + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_PASSWORD + any: true + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_HOST + value: redis.example.com + + - it: gateway seeds REDIS_CLUSTER_NODES from host and port in cluster mode + template: gateway/deployment.yaml + set: + redis.host: redis.example.com + redis.port: 6380 + redis.cluster: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_CLUSTER_NODES + value: '[{"host":"redis.example.com","port":6380}]' + + - it: gateway omits REDIS_CLUSTER_NODES when cluster mode is off + template: gateway/deployment.yaml + set: + redis.host: redis.example.com + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: REDIS_CLUSTER_NODES + any: true diff --git a/helm/litellm/tests/values/required.yaml b/helm/litellm/tests/values/required.yaml new file mode 100644 index 00000000000..21d3f7a5a6a --- /dev/null +++ b/helm/litellm/tests/values/required.yaml @@ -0,0 +1,4 @@ +database: + writer: + host: postgres.example.com + dbname: litellm diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml index 934661643bd..a8f2d39663e 100644 --- a/helm/litellm/values.yaml +++ b/helm/litellm/values.yaml @@ -100,7 +100,18 @@ database: usernameKey: username passwordKey: password -# Optional Redis (caching, rate limiting). Leave host empty to disable. +# Optional Redis. Leave host empty to disable. +# +# This is the proxy's coordination store: cross-pod tpm/rpm rate limits, spend +# tracking, and the pod lock manager. The chart emits REDIS_HOST / REDIS_PORT / +# REDIS_PASSWORD, which the proxy picks up through its coordination Redis env +# fallback. Response caching is separate and off unless you enable it in +# `proxy_config.litellm_settings.cache`. +# +# For full control, define `general_settings.coordination_redis` in +# `proxy_config` (host/port/password/username/url/ssl/startup_nodes/ +# sentinel_nodes/sentinel_password/service_name, each accepting os.environ/VAR +# refs). An explicit block overrides these env vars. # # Set `cluster: true` for Redis Cluster mode (e.g. AWS ElastiCache Cluster, # self-hosted Redis Cluster). The chart emits REDIS_CLUSTER_NODES from @@ -124,6 +135,11 @@ gateway: extraEnv: [] # Add extra environment variables to the gateway envConfigMaps: [] # Add extra environment variables to the gateway from config maps envSecrets: [] # Add extra environment variables to the gateway from secrets + # Additional volumes on the gateway Deployment (e.g. a ConfigMap holding + # custom callback / SSO handler code, mounted next to the proxy config). + volumes: [] + # Additional volumeMounts on the gateway container. + volumeMounts: [] config: create: true proxy_config: {} @@ -167,6 +183,10 @@ backend: extraEnv: [] envConfigMaps: [] envSecrets: [] + # Additional volumes on the backend Deployment. + volumes: [] + # Additional volumeMounts on the backend container. + volumeMounts: [] image: repository: ghcr.io/berriai/litellm-backend tag: "" @@ -206,6 +226,10 @@ ui: extraEnv: [] envConfigMaps: [] envSecrets: [] + # Additional volumes on the ui Deployment. + volumes: [] + # Additional volumeMounts on the ui container. + volumeMounts: [] image: repository: ghcr.io/berriai/litellm-ui tag: "" diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260629000000_add_max_concurrent_requests_to_mcp_server_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260629000000_add_max_concurrent_requests_to_mcp_server_table/migration.sql new file mode 100644 index 00000000000..eeeecce741d --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260629000000_add_max_concurrent_requests_to_mcp_server_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "max_concurrent_requests" INTEGER; diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260630120000_add_token_exchange_to_mcp_servers/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260630120000_add_token_exchange_to_mcp_servers/migration.sql new file mode 100644 index 00000000000..dec5fccc319 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260630120000_add_token_exchange_to_mcp_servers/migration.sql @@ -0,0 +1,8 @@ +-- Timestamp sorts before some already-applied migrations; this is safe: the +-- runner is `prisma migrate deploy`, which applies every pending migration +-- regardless of name order (utils.py has an informational check for exactly +-- this), and IF NOT EXISTS keeps a re-apply idempotent. +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "token_exchange_endpoint" TEXT; +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "audience" TEXT; +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "subject_token_type" TEXT; diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260630190000_add_budget_fallbacks_to_litellm_verification_token/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260630190000_add_budget_fallbacks_to_litellm_verification_token/migration.sql new file mode 100644 index 00000000000..1a5c16288de --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260630190000_add_budget_fallbacks_to_litellm_verification_token/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN IF NOT EXISTS "budget_fallbacks" JSONB NOT NULL DEFAULT '{}'; + +-- AlterTable +ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN IF NOT EXISTS "budget_fallbacks" JSONB NOT NULL DEFAULT '{}'; diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260703120000_add_token_exchange_profile_to_mcp_servers/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260703120000_add_token_exchange_profile_to_mcp_servers/migration.sql new file mode 100644 index 00000000000..6dda56c4fb3 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260703120000_add_token_exchange_profile_to_mcp_servers/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "token_exchange_profile" TEXT; diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710000000_add_dcr_bridge_to_mcp_server_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710000000_add_dcr_bridge_to_mcp_server_table/migration.sql new file mode 100644 index 00000000000..2cfabb9c02e --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260710000000_add_dcr_bridge_to_mcp_server_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "dcr_bridge" BOOLEAN; diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260713230852_add_key_type_to_litellm_verification_token/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260713230852_add_key_type_to_litellm_verification_token/migration.sql new file mode 100644 index 00000000000..708b7601346 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260713230852_add_key_type_to_litellm_verification_token/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN "key_type" TEXT; + +-- AlterTable +ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN "key_type" TEXT; + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index f6f6854d9b0..56787bb097d 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -332,15 +332,23 @@ model LiteLLM_MCPServerTable { token_url String? registration_url String? oauth2_flow String? + token_exchange_endpoint String? + // Named for the RFC 8693 "audience" token-exchange request parameter (that flow only). + // RFC 8707 resource indicators are a separate concept, named "resource" in the v2 egress types. + audience String? + subject_token_type String? + token_exchange_profile String? allow_all_keys Boolean @default(false) available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) oauth_passthrough Boolean @default(false) + dcr_bridge Boolean? is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? source_url String? timeout Float? + max_concurrent_requests Int? // BYOM submission lifecycle approval_status String? @default("active") submitted_by String? @@ -417,11 +425,13 @@ model LiteLLM_VerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this key (credential names) model_spend Json @default("{}") model_max_budget Json @default("{}") + budget_fallbacks Json @default("{}") budget_id String? organization_id String? object_permission_id String? @@ -511,11 +521,13 @@ model LiteLLM_DeletedVerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) logging_exporters String[] @default([]) model_spend Json @default("{}") model_max_budget Json @default("{}") + budget_fallbacks Json @default("{}") router_settings Json? @default("{}") budget_id String? organization_id String? diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index e2a86205fc5..b67d9d8570a 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.74" +version = "0.4.77" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.4.74" +version = "0.4.77" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/litellm/__init__.py b/litellm/__init__.py index eaf51aa202b..81ef933c855 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -380,6 +380,7 @@ budget_duration: Optional[str] = ( None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d"). ) default_soft_budget: float = DEFAULT_SOFT_BUDGET # by default all litellm proxy keys have a soft budget of 50.0 +budget_exceeded_throttle_percentage: Optional[float] = None forward_traceparent_to_llm_provider: bool = False @@ -589,6 +590,7 @@ gemini_models: Set = set() xai_models: Set = set() zai_models: Set = set() deepseek_models: Set = set() +tencent_models: Set = set() runwayml_models: Set = set() azure_ai_models: Set = set() jina_ai_models: Set = set() @@ -802,6 +804,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None): fal_ai_models.add(key) elif value.get("litellm_provider") == "deepseek": deepseek_models.add(key) + elif value.get("litellm_provider") == "tencent": + tencent_models.add(key) elif value.get("litellm_provider") == "runwayml": runwayml_models.add(key) elif value.get("litellm_provider") == "meta_llama": @@ -1094,6 +1098,7 @@ models_by_provider: dict = { "zai": zai_models, "fal_ai": fal_ai_models, "deepseek": deepseek_models, + "tencent": tencent_models, "runwayml": runwayml_models, "mistral": mistral_chat_models, "azure_ai": azure_ai_models, @@ -1805,6 +1810,9 @@ if TYPE_CHECKING: from .llms.deepseek.chat.transformation import ( DeepSeekChatConfig as _DeepSeekChatConfig, ) + from .llms.tencent.chat.transformation import ( + TencentChatConfig as _TencentChatConfig, + ) from .llms.sap.chat.transformation import ( GenAIHubOrchestrationConfig as _GenAIHubOrchestrationConfig, ) @@ -1847,6 +1855,7 @@ if TYPE_CHECKING: # Type stubs for lazy-loaded config classes (to help mypy understand types) VLLMConfig: Type[_VLLMConfig] DeepSeekChatConfig: Type[_DeepSeekChatConfig] + TencentChatConfig: Type[_TencentChatConfig] GenAIHubOrchestrationConfig: Type[_GenAIHubOrchestrationConfig] GenAIHubEmbeddingConfig: Type[_GenAIHubEmbeddingConfig] AzureOpenAIO1Config: Type[_AzureOpenAIO1Config] diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 0f9d3a560d1..488331e3895 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -284,6 +284,7 @@ LLM_CONFIG_NAMES = ( "LiteLLMProxyChatConfig", "VLLMConfig", "DeepSeekChatConfig", + "TencentChatConfig", "LMStudioChatConfig", "LmStudioEmbeddingConfig", "NscaleConfig", @@ -1096,6 +1097,7 @@ _LLM_CONFIGS_IMPORT_MAP = { ), "VLLMConfig": (".llms.vllm.completion.transformation", "VLLMConfig"), "DeepSeekChatConfig": (".llms.deepseek.chat.transformation", "DeepSeekChatConfig"), + "TencentChatConfig": (".llms.tencent.chat.transformation", "TencentChatConfig"), "LMStudioChatConfig": (".llms.lm_studio.chat.transformation", "LMStudioChatConfig"), "LmStudioEmbeddingConfig": ( ".llms.lm_studio.embed.transformation", diff --git a/litellm/_redis.py b/litellm/_redis.py index bb3a0974241..0b91cdabffc 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -325,8 +325,19 @@ def _get_redis_client_logic(**env_overrides): value = get_secret(v) # type: ignore env_overrides[k] = value + environment_kwargs = _redis_kwargs_from_environment() + + # An explicitly configured connection target outranks REDIS_URL from the + # environment. Without this, the url branch below strips the caller's + # host/port/password and silently connects to whatever REDIS_URL names. + caller_named_a_target = any( + env_overrides.get(key) is not None for key in ("host", "startup_nodes", "sentinel_nodes") + ) + if caller_named_a_target and env_overrides.get("url") is None: + environment_kwargs.pop("url", None) + redis_kwargs = { - **_redis_kwargs_from_environment(), + **environment_kwargs, **env_overrides, } @@ -678,9 +689,8 @@ def get_redis_connection_pool( redis_kwargs["credential_provider"] = GCPIAMCredentialProvider(redis_connect_func._gcp_service_account) connection_class = async_redis.Connection - if "ssl" in redis_kwargs: + if redis_kwargs.pop("ssl", False): connection_class = async_redis.SSLConnection - redis_kwargs.pop("ssl", None) redis_kwargs["connection_class"] = connection_class return async_redis.BlockingConnectionPool(timeout=REDIS_CONNECTION_POOL_TIMEOUT, **redis_kwargs) diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py index 37bf7c34f02..4c23ecfed54 100644 --- a/litellm/a2a_protocol/main.py +++ b/litellm/a2a_protocol/main.py @@ -129,6 +129,33 @@ def _set_agent_id_on_logging_obj( litellm_logging_obj.model_call_details["agent_id"] = agent_id +_A2A_COST_PARAM_KEYS = ("cost_per_query", "input_cost_per_token", "output_cost_per_token") + + +def _set_litellm_params_on_logging_obj( + kwargs: dict[str, Any], + litellm_params: dict[str, Any], +) -> None: + """ + Merge the agent's pricing params into model_call_details["litellm_params"] + so A2ACostCalculator can read them. + + The non-streaming path reuses the proxy-built logging object, whose + litellm_params already carries metadata / proxy_server_request / user-key + context, so merge the pricing keys in rather than replacing the dict. + """ + logging_obj = kwargs.get("litellm_logging_obj") + if logging_obj is None: + return + + cost_params = {key: litellm_params[key] for key in _A2A_COST_PARAM_KEYS if litellm_params.get(key) is not None} + if not cost_params: + return + + existing = logging_obj.model_call_details.get("litellm_params") or {} + logging_obj.model_call_details["litellm_params"] = {**existing, **cost_params} + + def _get_a2a_model_info(a2a_client: Any, kwargs: Dict[str, Any]) -> str: """ Extract agent info and set model/custom_llm_provider for cost tracking. @@ -477,6 +504,9 @@ async def asend_message( completion_tokens=completion_tokens, ) + # Merge agent pricing params into the logging obj so cost is calculated + _set_litellm_params_on_logging_obj(kwargs=kwargs, litellm_params=litellm_params) + # Set agent_id on logging obj for SpendLogs tracking _set_agent_id_on_logging_obj(kwargs=kwargs, agent_id=agent_id) diff --git a/litellm/a2a_protocol/streaming_iterator.py b/litellm/a2a_protocol/streaming_iterator.py index 529154919f3..1ef174a5eee 100644 --- a/litellm/a2a_protocol/streaming_iterator.py +++ b/litellm/a2a_protocol/streaming_iterator.py @@ -11,7 +11,6 @@ from litellm._logging import verbose_logger from litellm.a2a_protocol.cost_calculator import A2ACostCalculator from litellm.a2a_protocol.utils import A2ARequestUtils from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj -from litellm.litellm_core_utils.thread_pool_executor import executor if TYPE_CHECKING: from a2a.types import SendStreamingMessageRequest, SendStreamingMessageResponse @@ -128,22 +127,15 @@ class A2AStreamingIterator: # Call success handlers - they will build standard_logging_object asyncio.create_task( - self.logging_obj.async_success_handler( - result=result, + self.logging_obj.dispatch_success_handlers( + result, start_time=self.start_time, end_time=end_time, cache_hit=None, + prefer_async_handlers=True, ) ) - executor.submit( - self.logging_obj.success_handler, - result=result, - cache_hit=None, - start_time=self.start_time, - end_time=end_time, - ) - verbose_logger.info( f"A2A streaming completed: prompt_tokens={prompt_tokens}, " f"completion_tokens={completion_tokens}, total_tokens={total_tokens}, " diff --git a/litellm/a2a_protocol/utils.py b/litellm/a2a_protocol/utils.py index 0dbd1eefc63..ce5a168c3ac 100644 --- a/litellm/a2a_protocol/utils.py +++ b/litellm/a2a_protocol/utils.py @@ -121,8 +121,13 @@ class A2ARequestUtils: Returns: Tuple of (prompt_tokens, completion_tokens, total_tokens) """ - # Count input tokens + # Count input tokens. Dump the message to a dict first so extraction hits + # the dict branch — request-side parts are a2a-sdk Part RootModels whose + # kind/text live on part.root, which the object branch cannot read. This + # mirrors how the response side already works (it operates on model_dump). input_message = A2ARequestUtils.get_input_message_from_request(request) + if input_message is not None and hasattr(input_message, "model_dump"): + input_message = input_message.model_dump(mode="json") input_text = A2ARequestUtils.extract_text_from_message(input_message) prompt_tokens = A2ARequestUtils.count_tokens(input_text) diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json index 11fdb26e42d..3f6817f6e35 100644 --- a/litellm/anthropic_beta_headers_config.json +++ b/litellm/anthropic_beta_headers_config.json @@ -102,7 +102,7 @@ "computer-use-2025-01-24": "computer-use-2025-01-24", "computer-use-2025-11-24": "computer-use-2025-11-24", "context-1m-2025-08-07": "context-1m-2025-08-07", - "context-management-2025-06-27": null, + "context-management-2025-06-27": "context-management-2025-06-27", "effort-2025-11-24": "effort-2025-11-24", "fast-mode-2026-02-01": null, "files-api-2025-04-14": null, diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index 985198ce7ce..2fcb8455e90 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -3,6 +3,7 @@ from typing import Any, Iterator, List, Literal, Optional, Tuple import litellm from litellm._logging import verbose_logger +from litellm.litellm_core_utils.llm_cost_calc.utils import _parse_prompt_tokens_details from litellm.types.llms.openai import Batch from litellm.types.utils import CallTypes, ModelInfo, Usage from litellm.utils import token_counter @@ -34,7 +35,7 @@ async def calculate_batch_cost_and_usage( custom_llm_provider=custom_llm_provider, model_name=model_name, ) - batch_models = _get_batch_models_from_file_content(file_content_dictionary, model_name) + batch_models = _get_batch_models_from_file_content(file_content_dictionary, model_name, custom_llm_provider) return batch_cost, batch_usage, batch_models @@ -70,7 +71,7 @@ async def _handle_completed_batch( model_name=model_name, ) - batch_models = _get_batch_models_from_file_content(file_content_dictionary, model_name) + batch_models = _get_batch_models_from_file_content(file_content_dictionary, model_name, custom_llm_provider) return batch_cost, batch_usage, batch_models @@ -78,6 +79,7 @@ async def _handle_completed_batch( def _get_batch_models_from_file_content( file_content_dictionary: List[dict], model_name: Optional[str] = None, + custom_llm_provider: str = "openai", ) -> List[str]: """ Get the models from the file content @@ -86,8 +88,8 @@ def _get_batch_models_from_file_content( return [model_name] batch_models = [] for _item in file_content_dictionary: - if _batch_response_was_successful(_item): - _response_body = _get_response_from_batch_job_output_file(_item) + if _batch_response_was_successful(_item, custom_llm_provider): + _response_body = _get_response_from_batch_job_output_file(_item, custom_llm_provider) _model = _response_body.get("model") if _model: batch_models.append(_model) @@ -116,6 +118,7 @@ def _batch_cost_calculator( total_cost = _get_batch_job_cost_from_file_content( file_content_dictionary=file_content_dictionary, custom_llm_provider=custom_llm_provider, + model_name=model_name, model_info=model_info, ) verbose_logger.debug("total_cost=%s", total_cost) @@ -361,6 +364,7 @@ def _count_entry_tokens( def _get_batch_job_cost_from_file_content( file_content_dictionary: List[dict], custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"] = "openai", + model_name: Optional[str] = None, model_info: Optional[ModelInfo] = None, ) -> float: """ @@ -373,11 +377,17 @@ def _get_batch_job_cost_from_file_content( # parse the file content as json verbose_logger.debug("file_content_dictionary=%s", json.dumps(file_content_dictionary, indent=4)) for _item in file_content_dictionary: - if _batch_response_was_successful(_item): - _response_body = _get_response_from_batch_job_output_file(_item) - if model_info is not None: - usage = _get_batch_job_usage_from_response_body(_response_body) - model = _response_body.get("model", "") + if _batch_response_was_successful(_item, custom_llm_provider): + _response_body = _get_response_from_batch_job_output_file(_item, custom_llm_provider) + if model_info is not None or custom_llm_provider in ("anthropic", "bedrock"): + usage = _get_batch_job_usage_from_response_body(_response_body, custom_llm_provider) + # Bedrock batch output lines report a short internal model id + # (e.g. "claude-sonnet-4-6") that is not in the cost map; use the + # deployment model name for pricing when available. + if custom_llm_provider == "bedrock" and model_name: + model = model_name + else: + model = _response_body.get("model") or model_name or "" prompt_cost, completion_cost = batch_cost_calculator( usage=usage, model=model, @@ -418,17 +428,31 @@ def _get_batch_job_total_usage_from_file_content( total_tokens: int = 0 prompt_tokens: int = 0 completion_tokens: int = 0 + cache_read_tokens: int = 0 + cache_creation_tokens: int = 0 for _item in file_content_dictionary: - if _batch_response_was_successful(_item): - _response_body = _get_response_from_batch_job_output_file(_item) - usage: Usage = _get_batch_job_usage_from_response_body(_response_body) + if _batch_response_was_successful(_item, custom_llm_provider): + _response_body = _get_response_from_batch_job_output_file(_item, custom_llm_provider) + usage: Usage = _get_batch_job_usage_from_response_body(_response_body, custom_llm_provider) total_tokens += usage.total_tokens prompt_tokens += usage.prompt_tokens completion_tokens += usage.completion_tokens + prompt_details = _parse_prompt_tokens_details(usage) + cache_read_tokens += prompt_details["cache_hit_tokens"] + cache_creation_tokens += prompt_details["cache_creation_tokens"] + cache_token_params = { + key: tokens + for key, tokens in ( + ("cache_read_input_tokens", cache_read_tokens), + ("cache_creation_input_tokens", cache_creation_tokens), + ) + if tokens > 0 + } return Usage( total_tokens=total_tokens, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, + **cache_token_params, ) @@ -465,27 +489,56 @@ def _count_prompt_or_input_tokens(model: str, value: Any) -> int: return 0 -def _get_batch_job_usage_from_response_body(response_body: dict) -> Usage: +def _get_batch_job_usage_from_response_body(response_body: dict, custom_llm_provider: str = "openai") -> Usage: """ Get the tokens of a batch job from the response body """ + if custom_llm_provider in ("anthropic", "bedrock"): + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + return AnthropicConfig().calculate_usage( + usage_object=response_body.get("usage", None) or {}, + reasoning_content=None, + ) _usage_dict = response_body.get("usage", None) or {} usage: Usage = Usage(**_usage_dict) return usage -def _get_response_from_batch_job_output_file(batch_job_output_file: dict) -> Any: +def _get_anthropic_result_from_batch_results_line(batch_results_line: dict) -> dict: + """ + Get the ``result`` object from a line of an Anthropic message batch results JSONL file. + + Anthropic batch results lines look like: + ``{"custom_id": ..., "result": {"type": "succeeded", "message": {..., "usage": {...}}}}`` + """ + return batch_results_line.get("result", None) or {} + + +def _get_response_from_batch_job_output_file(batch_job_output_file: dict, custom_llm_provider: str = "openai") -> Any: """ Get the response from the batch job output file """ + if custom_llm_provider == "anthropic": + return _get_anthropic_result_from_batch_results_line(batch_job_output_file).get("message", None) or {} + if custom_llm_provider == "bedrock": + return batch_job_output_file.get("modelOutput", None) or {} _response: dict = batch_job_output_file.get("response", None) or {} _response_body = _response.get("body", None) or {} return _response_body -def _batch_response_was_successful(batch_job_output_file: dict) -> bool: +def _batch_response_was_successful(batch_job_output_file: dict, custom_llm_provider: str = "openai") -> bool: """ - Check if the batch job response status == 200 + Check if the batch job response was successful + + OpenAI-shaped output rows report ``response.status_code == 200``; Anthropic + message batch results lines report ``result.type == "succeeded"``; Bedrock + batch output lines report ``modelOutput`` (and no ``error``). """ + if custom_llm_provider == "anthropic": + return _get_anthropic_result_from_batch_results_line(batch_job_output_file).get("type") == "succeeded" + if custom_llm_provider == "bedrock": + return batch_job_output_file.get("modelOutput") is not None and batch_job_output_file.get("error") is None _response: dict = batch_job_output_file.get("response", None) or {} return _response.get("status_code", None) == 200 diff --git a/litellm/caching/disk_cache.py b/litellm/caching/disk_cache.py index b51acbe9cfd..d9f65ce949e 100644 --- a/litellm/caching/disk_cache.py +++ b/litellm/caching/disk_cache.py @@ -59,8 +59,9 @@ class DiskCache(BaseCache): def increment_cache(self, key, value: int, **kwargs) -> int: # get the value - init_value = self.get_cache(key=key) or 0 - value = init_value + value # type: ignore + cached_value = self.get_cache(key=key) + init_value = cached_value if isinstance(cached_value, int) else 0 + value = init_value + value self.set_cache(key, value, **kwargs) return value @@ -76,8 +77,9 @@ class DiskCache(BaseCache): async def async_increment(self, key, value: int, **kwargs) -> int: # get the value - init_value = await self.async_get_cache(key=key) or 0 - value = init_value + value # type: ignore + cached_value = await self.async_get_cache(key=key) + init_value = cached_value if isinstance(cached_value, int) else 0 + value = init_value + value await self.async_set_cache(key, value, **kwargs) return value diff --git a/litellm/caching/valkey_semantic_cache.py b/litellm/caching/valkey_semantic_cache.py index 746e91207d8..76b7f7d5b87 100644 --- a/litellm/caching/valkey_semantic_cache.py +++ b/litellm/caching/valkey_semantic_cache.py @@ -279,7 +279,7 @@ class ValkeySemanticCache(RedisSemanticCache): print_verbose("No prompt provided for semantic caching") return - embedding = await self._get_async_embedding(prompt, **kwargs) + embedding = await self._get_async_embedding(prompt, metadata=kwargs.get("metadata")) await self._ensure_index_async(len(embedding)) doc_key = self._doc_key(key) @@ -298,7 +298,7 @@ class ValkeySemanticCache(RedisSemanticCache): kwargs.setdefault("metadata", {})["semantic-similarity"] = 0.0 return None - embedding = await self._get_async_embedding(prompt, **kwargs) + embedding = await self._get_async_embedding(prompt, metadata=kwargs.get("metadata")) await self._ensure_index_async(len(embedding)) search_result = await self.async_client.ft(self.index_name).search( diff --git a/litellm/constants.py b/litellm/constants.py index dd74b9e7bac..fc788d5ecb5 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -513,6 +513,7 @@ LITELLM_CHAT_PROVIDERS = [ "text-completion-codestral", "text-completion-inception", "deepseek", + "tencent", "sambanova", "maritalk", "cloudflare", @@ -719,6 +720,7 @@ openai_compatible_endpoints: List = [ "https://api.clarifai.com/v2/ext/openai/v1", "https://api.libertai.io/v1", "https://pinstripes.io/v1", + "https://api.meta.ai/v1", ] @@ -734,6 +736,7 @@ openai_compatible_providers: List = [ "volcengine", "codestral", "deepseek", + "tencent", "deepinfra", "perplexity", "xinference", @@ -784,6 +787,7 @@ openai_compatible_providers: List = [ "ragflow", "pinstripes", # Pinstripes - JSON-configured provider "darkbloom", + "meta", # Meta Model API (Muse Spark) - JSON-configured provider ] openai_text_completion_compatible_providers: List = [ # providers that support `/v1/completions` "together_ai", @@ -1507,6 +1511,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [ "public_model_groups_links", "cost_discount_config", "cost_margin_config", + "budget_exceeded_throttle_percentage", ] SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"] DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60)) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index e8535a570c8..a40a8e1389c 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -52,6 +52,9 @@ from litellm.llms.databricks.cost_calculator import ( from litellm.llms.deepseek.cost_calculator import ( cost_per_token as deepseek_cost_per_token, ) +from litellm.llms.tencent.cost_calculator import ( + cost_per_token as tencent_cost_per_token, +) from litellm.llms.fireworks_ai.cost_calculator import ( cost_per_token as fireworks_ai_cost_per_token, ) @@ -219,7 +222,7 @@ def _cost_per_token_custom_pricing_helper( output_cost = completion_tokens * output_cost_per_token return input_cost, output_cost elif custom_cost_per_second is not None: - output_cost = custom_cost_per_second * response_time_ms / 1000 # type: ignore + output_cost = custom_cost_per_second * (response_time_ms or 0.0) / 1000 return 0, output_cost return None @@ -625,6 +628,8 @@ def cost_per_token( return gemini_cost_per_token(model=model, usage=usage_block, service_tier=service_tier) elif custom_llm_provider == "deepseek": return deepseek_cost_per_token(model=model, usage=usage_block) + elif custom_llm_provider == "tencent": + return tencent_cost_per_token(model=model, usage=usage_block) elif custom_llm_provider == "perplexity": return perplexity_cost_per_token(model=model, usage=usage_block) elif custom_llm_provider == "xai": @@ -657,29 +662,27 @@ def cost_per_token( data_residency=data_residency, ) - if model_info.get("input_cost_per_second", None) is not None and response_time_ms is not None: + input_cost_per_second = model_info.get("input_cost_per_second") + if input_cost_per_second is not None and response_time_ms is not None: verbose_logger.debug( "For model=%s - input_cost_per_second: %s; response time: %s", model, - model_info.get("input_cost_per_second", None), + input_cost_per_second, response_time_ms, ) ## COST PER SECOND ## - prompt_tokens_cost_usd_dollar = ( - model_info["input_cost_per_second"] * response_time_ms / 1000 # type: ignore - ) + prompt_tokens_cost_usd_dollar = input_cost_per_second * response_time_ms / 1000 - if model_info.get("output_cost_per_second", None) is not None and response_time_ms is not None: + output_cost_per_second = model_info.get("output_cost_per_second") + if output_cost_per_second is not None and response_time_ms is not None: verbose_logger.debug( "For model=%s - output_cost_per_second: %s; response time: %s", model, - model_info.get("output_cost_per_second", None), + output_cost_per_second, response_time_ms, ) ## COST PER SECOND ## - completion_tokens_cost_usd_dollar = ( - model_info["output_cost_per_second"] * response_time_ms / 1000 # type: ignore - ) + completion_tokens_cost_usd_dollar = output_cost_per_second * response_time_ms / 1000 verbose_logger.debug( "Returned custom cost for model=%s - prompt_tokens_cost_usd_dollar: %s, completion_tokens_cost_usd_dollar: %s", @@ -757,7 +760,11 @@ def _select_model_name_for_cost_calc( if custom_pricing is True: if router_model_id is not None and router_model_id in litellm.model_cost: entry = litellm.model_cost[router_model_id] - if entry.get("input_cost_per_token") is not None or entry.get("input_cost_per_second") is not None: + if ( + entry.get("input_cost_per_token") is not None + or entry.get("input_cost_per_second") is not None + or entry.get("tiered_pricing") is not None + ): return_model = router_model_id else: return_model = model @@ -1495,6 +1502,7 @@ def completion_cost( custom_llm_provider=custom_llm_provider, litellm_model_name=model, data_residency=data_residency, + litellm_logging_obj=litellm_logging_obj, ) elif call_type == _MCP_CALL_TYPE: from litellm.proxy._experimental.mcp_server.cost_calculator import ( @@ -2151,17 +2159,23 @@ def batch_cost_calculator( if input_cost_per_token_batches: total_prompt_cost = usage.prompt_tokens * input_cost_per_token_batches elif input_cost_per_token: + details = _parse_prompt_tokens_details(usage) + cache_read_tokens = details["cache_hit_tokens"] + cache_creation_tokens = details["cache_creation_tokens"] + # Subtract cached tokens from prompt_tokens before calculating cost # Fixes issue where cached tokens are being charged again + base_input_tokens = get_billable_input_tokens(usage) - cache_creation_tokens total_prompt_cost = ( - get_billable_input_tokens(usage) * (input_cost_per_token) / 2 + base_input_tokens * (input_cost_per_token) / 2 ) # batch cost is usually half of the regular token cost # Add cache read cost if applicable - details = _parse_prompt_tokens_details(usage) - cache_read_tokens = details["cache_hit_tokens"] cache_read_cost_key = _get_service_tier_cost_key("cache_read_input_token_cost", None) total_prompt_cost += calculate_cost_component(model_info, cache_read_cost_key, cache_read_tokens) / 2 + + cache_creation_cost = model_info.get("cache_creation_input_token_cost") or input_cost_per_token + total_prompt_cost += cache_creation_tokens * cache_creation_cost / 2 if output_cost_per_token_batches: total_completion_cost = usage.completion_tokens * output_cost_per_token_batches elif output_cost_per_token: @@ -2297,6 +2311,7 @@ def handle_realtime_stream_cost_calculation( custom_llm_provider: str, litellm_model_name: str, data_residency: Optional[str] = None, + litellm_logging_obj: Optional[LitellmLoggingObject] = None, ) -> float: """ Handles the cost calculation for realtime stream responses. @@ -2332,14 +2347,25 @@ def handle_realtime_stream_cost_calculation( input_cost_per_token += _input_cost_per_token output_cost_per_token += _output_cost_per_token break # exit if we find a valid model - total_cost = input_cost_per_token + output_cost_per_token - - if any(r.get("type") == _TRANSCRIPTION_COMPLETED_EVENT_TYPE for r in results): - total_cost += handle_realtime_transcription_cost_calculation( + transcription_cost = ( + handle_realtime_transcription_cost_calculation( results=results, custom_llm_provider=custom_llm_provider, litellm_model_name=litellm_model_name, ) + if any(r.get("type") == _TRANSCRIPTION_COMPLETED_EVENT_TYPE for r in results) + else 0.0 + ) + total_cost = input_cost_per_token + output_cost_per_token + transcription_cost + + _store_cost_breakdown_in_logging_obj( + litellm_logging_obj=litellm_logging_obj, + prompt_tokens_cost_usd_dollar=input_cost_per_token, + completion_tokens_cost_usd_dollar=output_cost_per_token, + cost_for_built_in_tools_cost_usd_dollar=0.0, + total_cost_usd_dollar=total_cost, + additional_costs={"transcription_cost": transcription_cost} if transcription_cost > 0 else None, + ) return total_cost diff --git a/litellm/exceptions.py b/litellm/exceptions.py index d97ba347b07..aca3fb551cc 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -1165,29 +1165,21 @@ class ModifyResponseException(Exception): request_data: Dict[str, Any], guardrail_name: Optional[str] = None, detection_info: Optional[Dict[str, Any]] = None, + original_response: Optional[Any] = None, ): self.message = message self.model = model self.request_data = request_data self.guardrail_name = guardrail_name self.detection_info = detection_info or {} + # The LLM response that was blocked (post-call). Carries the real token + # usage the upstream call consumed, so the synthetic block response can + # report it instead of discarding it. None for pre-call blocks (the LLM + # was never invoked). + self.original_response = original_response super().__init__(message) -class GuardrailInterventionNormalStringError( - Exception -): # custom exception to raise when a guardrail intervenes, but we want to return a normal string to the user - def __init__(self, message: str): - self.message = message - super().__init__(self.message) - - def __str__(self): - return self.message - - def __repr__(self): - return self.__str__() - - class SensitiveDataRouteException(Exception): """ Exception raised when a guardrail detects sensitive data and wants to reroute the request. diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index 831e588e5ba..da711463a44 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -382,15 +382,25 @@ class MCPClient: if root_cause is not None and isinstance(in_flight_error, asyncio.CancelledError): raise root_cause from in_flight_error - async def run_with_session(self, operation: Callable[[ClientSession], Awaitable[TSessionResult]]) -> TSessionResult: - """Open a session, run the provided coroutine, and clean up.""" + async def run_with_session( + self, + operation: Callable[[ClientSession], Awaitable[TSessionResult]], + *, + quiet_on_error: bool = False, + ) -> TSessionResult: + """Open a session, run the provided coroutine, and clean up. + + quiet_on_error demotes the failure line to debug for callers that own the exception + (call_tool / list_tools under raise_on_error), so an expected pass-through re-auth does + not emit a warning per call; every other caller keeps the operator-visible warning.""" http_client: Optional[httpx.AsyncClient] = None try: self._last_initialize_instructions = None transport_ctx, http_client = self._create_transport_context() return await self._execute_session_operation(transport_ctx, operation) except Exception: - verbose_logger.warning("MCP client run_with_session failed for %s", self.server_url or "stdio") + _log = verbose_logger.debug if quiet_on_error else verbose_logger.warning + _log("MCP client run_with_session failed for %s", self.server_url or "stdio") raise finally: if http_client is not None: @@ -491,7 +501,7 @@ class MCPClient: return await session.list_tools() try: - result = await self.run_with_session(_list_tools_operation) + result = await self.run_with_session(_list_tools_operation, quiet_on_error=raise_on_error) tool_count = len(result.tools) tool_names = [tool.name for tool in result.tools] verbose_logger.info(f"MCP client listed {tool_count} tools from {self.server_url or 'stdio'}: {tool_names}") @@ -501,7 +511,13 @@ class MCPClient: raise except Exception as e: error_type = type(e).__name__ - verbose_logger.exception( + # Mirror call_tool: when the caller opted into raise_on_error it owns the exception and + # logs it at the fitting level (an expected pass-through re-auth 401 is info, not an + # error), so log at debug here to avoid an error-level line + traceback that would trip + # error-rate alerts on that expected signal. The swallow path still logs the full + # exception because nothing downstream will surface the failure. + _log = verbose_logger.debug if raise_on_error else verbose_logger.exception + _log( f"MCP client list_tools failed - " f"Error Type: {error_type}, " f"Error: {str(e)}, " @@ -510,7 +526,8 @@ class MCPClient: ) # Check if it's a stream/connection error if "BrokenResourceError" in error_type or "Broken" in error_type: - verbose_logger.error( + _log_broken = verbose_logger.debug if raise_on_error else verbose_logger.error + _log_broken( "MCP client detected broken connection/stream during list_tools - " "the MCP server may have crashed, disconnected, or timed out" ) @@ -520,13 +537,28 @@ class MCPClient: # Return empty list instead of raising to allow graceful degradation return [] + @staticmethod + def error_tool_result(exc: Exception) -> MCPCallToolResult: + """The error result ``call_tool`` returns when it swallows a failure (no re-execution).""" + return MCPCallToolResult( + content=[TextContent(type="text", text=f"{type(exc).__name__}: {str(exc)}")], + isError=True, + ) + async def call_tool( self, call_tool_request_params: MCPCallToolRequestParams, host_progress_callback: Optional[Callable] = None, + raise_on_error: bool = False, ) -> MCPCallToolResult: """ Call an MCP Tool. + + Args: + raise_on_error: When True, re-raise the underlying exception instead of returning an + ``isError=True`` result. The token-exchange (OBO) tool-call path uses this to detect + an upstream 401 so it can re-mint the exchanged token and retry once; every other + caller keeps the default and gets graceful ``isError`` degradation. """ verbose_logger.info(f"MCP client calling tool '{call_tool_request_params.name}'") @@ -552,7 +584,7 @@ class MCPClient: ) try: - tool_result = await self.run_with_session(_call_tool_operation) + tool_result = await self.run_with_session(_call_tool_operation, quiet_on_error=raise_on_error) verbose_logger.info(f"MCP client tool call '{call_tool_request_params.name}' completed successfully") return tool_result except asyncio.CancelledError: @@ -565,7 +597,13 @@ class MCPClient: verbose_logger.debug(f"MCP client tool call traceback:\n{error_trace}") # Log detailed error information error_type = type(e).__name__ - verbose_logger.error( + # When the caller opted into raise_on_error it owns the exception and logs it at the + # level that fits (an expected pass-through re-auth 401 is info, not an operator-actionable + # error), so log at debug here to avoid an error-level line that would trip error-rate + # alerts on that expected signal. The swallow path (raise_on_error=False) still logs at + # error because nothing downstream will surface the failure. + _log = verbose_logger.debug if raise_on_error else verbose_logger.error + _log( f"MCP client call_tool failed - " f"Error Type: {error_type}, " f"Error: {str(e)}, " @@ -575,15 +613,14 @@ class MCPClient: ) # Check if it's a stream/connection error if "BrokenResourceError" in error_type or "Broken" in error_type: - verbose_logger.error( + _log( "MCP client detected broken connection/stream - " "the MCP server may have crashed, disconnected, or timed out." ) + if raise_on_error: + raise # Return a default error result instead of raising - return MCPCallToolResult( - content=[TextContent(type="text", text=f"{error_type}: {str(e)}")], # Empty content for error case - isError=True, - ) + return self.error_tool_result(e) async def list_prompts(self) -> List[Prompt]: """List available prompts from the server.""" diff --git a/litellm/fine_tuning/main.py b/litellm/fine_tuning/main.py index 8a8a916fa9c..ce5074cdaf5 100644 --- a/litellm/fine_tuning/main.py +++ b/litellm/fine_tuning/main.py @@ -256,8 +256,6 @@ def create_fine_tuning_job( extra_body = optional_params.get("extra_body", {}) if extra_body is not None: extra_body.pop("azure_ad_token", None) - else: - get_secret_str("AZURE_AD_TOKEN") # type: ignore # Prepare Azure-specific parameters for extra_body extra_body = _prepare_azure_extra_body(extra_body, kwargs, azure_specific_hyperparams) @@ -442,7 +440,7 @@ def cancel_fine_tuning_job( ) # Azure OpenAI elif custom_llm_provider == "azure": - api_base = optional_params.api_base or litellm.api_base or get_secret("AZURE_API_BASE") # type: ignore + api_base = optional_params.api_base or litellm.api_base or get_secret_str("AZURE_API_BASE") api_version = optional_params.api_version or litellm.api_version or get_secret_str("AZURE_API_VERSION") # type: ignore @@ -457,8 +455,6 @@ def cancel_fine_tuning_job( extra_body = optional_params.get("extra_body", {}) if extra_body is not None: extra_body.pop("azure_ad_token", None) - else: - get_secret_str("AZURE_AD_TOKEN") # type: ignore response = azure_fine_tuning_apis_instance.cancel_fine_tuning_job( api_base=api_base, @@ -616,8 +612,6 @@ def list_fine_tuning_jobs( extra_body = optional_params.get("extra_body", {}) if extra_body is not None: extra_body.pop("azure_ad_token", None) - else: - get_secret("AZURE_AD_TOKEN") # type: ignore response = azure_fine_tuning_apis_instance.list_fine_tuning_jobs( api_base=api_base, @@ -759,8 +753,6 @@ def retrieve_fine_tuning_job( extra_body = optional_params.get("extra_body", {}) if extra_body is not None: extra_body.pop("azure_ad_token", None) - else: - get_secret_str("AZURE_AD_TOKEN") # type: ignore response = azure_fine_tuning_apis_instance.retrieve_fine_tuning_job( api_base=api_base, diff --git a/litellm/integrations/azure_sentinel/azure_sentinel.py b/litellm/integrations/azure_sentinel/azure_sentinel.py index 182a2e185ef..5f8afe58cb0 100644 --- a/litellm/integrations/azure_sentinel/azure_sentinel.py +++ b/litellm/integrations/azure_sentinel/azure_sentinel.py @@ -61,13 +61,15 @@ class AzureSentinelLogger(CustomBatchLogger): client_secret (str, optional): Azure Client Secret for OAuth2 authentication. If not provided, will use AZURE_SENTINEL_CLIENT_SECRET or AZURE_CLIENT_SECRET env var. audit_stream_name (str, optional): Stream name from DCR for audit logs. - If not provided, audit logs use the standard stream name. + If not provided, will use AZURE_SENTINEL_AUDIT_STREAM_NAME env var or the standard stream name. """ self.async_httpx_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.LoggingCallback) resolved_dcr_immutable_id = dcr_immutable_id or os.getenv("AZURE_SENTINEL_DCR_IMMUTABLE_ID") resolved_stream_name = stream_name or os.getenv("AZURE_SENTINEL_STREAM_NAME") or "Custom-LiteLLM" - resolved_audit_stream_name = audit_stream_name or resolved_stream_name + resolved_audit_stream_name = ( + audit_stream_name or os.getenv("AZURE_SENTINEL_AUDIT_STREAM_NAME") or resolved_stream_name + ) resolved_endpoint = endpoint or os.getenv("AZURE_SENTINEL_ENDPOINT") resolved_tenant_id = tenant_id or os.getenv("AZURE_SENTINEL_TENANT_ID") or os.getenv("AZURE_TENANT_ID") resolved_client_id = client_id or os.getenv("AZURE_SENTINEL_CLIENT_ID") or os.getenv("AZURE_CLIENT_ID") diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 59d37639098..856556f7c56 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -1,3 +1,4 @@ +import os import secrets from datetime import datetime from typing import ( @@ -17,6 +18,7 @@ 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.secret_managers.main import str_to_bool from litellm.types.guardrails import ( DynamicGuardrailParams, GuardrailEventHooks, @@ -59,6 +61,20 @@ from litellm.exceptions import ( _PRE_CALL_EXECUTED_TOKEN = secrets.token_hex(16) +def _strict_guardrail_modes_enabled() -> bool: + """Whether guardrail-mode validation raises (default) or logs a warning. + + Set `LITELLM_STRICT_GUARDRAIL_MODES=false` to keep the pre-LIT-4226 behavior + for guardrails whose supported_event_hooks list newly includes their + configured mode: log the mismatch and continue instead of raising at boot. + """ + raw = os.environ.get("LITELLM_STRICT_GUARDRAIL_MODES") + if raw is None: + return True + parsed = str_to_bool(raw) + return True if parsed is None else parsed + + def get_session_id_from_request_data(request_data: Dict[str, Any]) -> Optional[str]: """Extract session_id from request data (litellm_session_id or metadata).""" session_id = request_data.get("litellm_session_id") @@ -132,7 +148,17 @@ class CustomGuardrail(CustomLogger): if supported_event_hooks: ## validate event_hook is in supported_event_hooks - self._validate_event_hook(event_hook, supported_event_hooks) + try: + self._validate_event_hook(event_hook, supported_event_hooks) + except ValueError as validation_error: + if _strict_guardrail_modes_enabled(): + raise + verbose_logger.warning( + "%s. LITELLM_STRICT_GUARDRAIL_MODES=false; continuing " + "with unsupported event_hook. Set the env var to true " + "(default) to enforce validation and fail at startup.", + validation_error, + ) super().__init__(**kwargs) def render_violation_message(self, default: str, context: Optional[Dict[str, Any]] = None) -> str: @@ -303,6 +329,18 @@ class CustomGuardrail(CustomLogger): """ return None + @classmethod + def get_supported_event_hooks(cls) -> Optional[List[GuardrailEventHooks]]: + """ + Returns the event hooks this guardrail supports, for the UI to render. + + Subclasses should override to return their supported hooks list. When a + subclass returns None, the endpoint omits it from the per-provider map + and the UI is expected to fall back to the global `supported_modes` + list client-side. + """ + return None + def _validate_event_hook( self, event_hook: Optional[Union[GuardrailEventHooks, List[GuardrailEventHooks], Mode]], @@ -477,6 +515,22 @@ class CustomGuardrail(CustomLogger): return True return False + def uses_apply_guardrail_interface(self) -> bool: + return type(self).apply_guardrail is not CustomGuardrail.apply_guardrail + + def _deployment_pre_call_target(self) -> "CustomLogger": + if not self.uses_apply_guardrail_interface(): + return self + try: + from litellm.proxy.utils import unified_guardrail + except ImportError as e: + raise ImportError( + f"Guardrail {self.guardrail_name or type(self).__name__} implements apply_guardrail, which needs " + "the litellm proxy dependencies to run at the deployment level. " + "Install them with: pip install 'litellm[proxy]'" + ) from e + return unified_guardrail + async def async_pre_call_deployment_hook( self, kwargs: Dict[str, Any], call_type: Optional[CallTypes] ) -> Optional[dict]: @@ -495,7 +549,10 @@ class CustomGuardrail(CustomLogger): # CHECK IF GUARDRAIL REJECTS THE REQUEST if call_type == CallTypes.completion or call_type == CallTypes.acompletion: - result = await self.async_pre_call_hook( + target = self._deployment_pre_call_target() + if target is not self: + kwargs["guardrail_to_apply"] = self + result = await target.async_pre_call_hook( user_api_key_dict=UserAPIKeyAuth( user_id=kwargs.get("user_api_key_user_id"), team_id=kwargs.get("user_api_key_team_id"), @@ -505,7 +562,7 @@ class CustomGuardrail(CustomLogger): ), cache=dc, data=kwargs, - call_type=call_type.value or "acompletion", # type: ignore + call_type="completion" if call_type == CallTypes.completion else "acompletion", ) if result is not None and isinstance(result, dict): @@ -757,6 +814,12 @@ class CustomGuardrail(CustomLogger): # raw provider JSON so redaction is not duplicated upstream). clean_guardrail_response = redact_nested_match_and_regex_keys(clean_guardrail_response) + from litellm.litellm_core_utils.sensitive_data_masker import ( + mask_credentials_in_payload, + ) + + clean_guardrail_response = mask_credentials_in_payload(clean_guardrail_response) + slg = StandardLoggingGuardrailInformation( guardrail_name=self.guardrail_name, guardrail_provider=guardrail_provider, diff --git a/litellm/integrations/datadog/datadog.py b/litellm/integrations/datadog/datadog.py index 6775858c124..20239d831cc 100644 --- a/litellm/integrations/datadog/datadog.py +++ b/litellm/integrations/datadog/datadog.py @@ -19,7 +19,7 @@ import os import time import traceback from datetime import datetime as datetimeObj -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Sequence, Union import httpx from httpx import Response @@ -50,6 +50,7 @@ from litellm.types.integrations.base_health_check import IntegrationHealthCheckS from litellm.types.integrations.datadog import ( DD_ERRORS, DD_MAX_BATCH_SIZE, + DD_MAX_PAYLOAD_SIZE_BYTES, DataDogStatus, DatadogInitParams, DatadogPayload, @@ -354,14 +355,14 @@ class DataDogLogger( Raises: Raises a NON Blocking verbose_logger.exception if an error occurs """ + if not self.log_queue: + verbose_logger.exception("Datadog: log_queue does not exist") + return + + batch_to_send = self.log_queue[:] + self.log_queue = [] + try: - if not self.log_queue: - verbose_logger.exception("Datadog: log_queue does not exist") - return - - batch_to_send = self.log_queue[:] - self.log_queue = [] - verbose_logger.debug( "Datadog - about to flush %s events on %s", len(batch_to_send), @@ -384,8 +385,10 @@ class DataDogLogger( async def _send_with_413_split(self, batch: List) -> List: """ - Send a batch, halving any sub-batch that 413s (payload too large) and retrying the - halves, since Datadog enforces a 5MB uncompressed limit per request. + Send a batch, halving any sub-batch that exceeds Datadog's intake limits before + sending, and halving again on a 413 (payload too large) response, since Datadog + enforces a 5MB uncompressed limit per request. The proactive split avoids paying + a serialize + gzip + round trip for a payload the intake is guaranteed to reject. A 413 surfaces as a raised MaskedHTTPStatusError (httpx raise_for_status), not a returned response, so both paths are handled. A lone event that still 413s is @@ -398,6 +401,11 @@ class DataDogLogger( chunk = pending.pop() if not chunk: continue + if len(chunk) > 1 and self._exceeds_intake_limits(chunk): + mid = len(chunk) // 2 + pending.append(chunk[mid:]) + pending.append(chunk[:mid]) + continue try: response = await self.async_send_compressed_data(chunk) except Exception as e: @@ -436,6 +444,21 @@ class DataDogLogger( def _undelivered(chunk: List, pending: List[List]) -> List: return chunk + [event for remaining in reversed(pending) for event in remaining] + @staticmethod + def _exceeds_intake_limits(chunk: Sequence[DatadogPayload]) -> bool: + """ + True when a chunk would breach Datadog's log intake limits: more than + DD_MAX_BATCH_SIZE events per payload, or a serialized size above + DD_MAX_PAYLOAD_SIZE_BYTES (held under Datadog's 5MB uncompressed cap so + the batch is split before the intake rejects it with a 413). + """ + from litellm.litellm_core_utils.safe_json_dumps import safe_dumps + + if len(chunk) > DD_MAX_BATCH_SIZE: + return True + payload_size_bytes = len(safe_dumps(chunk).encode("utf-8")) + return payload_size_bytes > DD_MAX_PAYLOAD_SIZE_BYTES + async def flush_queue(self): if self.flush_lock is None: return diff --git a/litellm/integrations/otel/README.md b/litellm/integrations/otel/README.md index fb584ccdb15..6e564635ee0 100644 --- a/litellm/integrations/otel/README.md +++ b/litellm/integrations/otel/README.md @@ -223,6 +223,15 @@ lives in [`plumbing/`](./plumbing): readers/exporters receive them alongside the server metrics, and one is built and registered as the global only when none is set (mirroring how V2 owns trace export). +- [`events.py`](./plumbing/events.py) — GenAI client events. Gated on + `enable_events` (`LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS`), a failed LLM call + records the semconv `gen_ai.client.operation.exception` log event at severity + WARN, carrying `exception.type` / `exception.message` / `exception.stacktrace` + and correlated to the failed span through the trace and span ids. The + `LoggerProvider` is resolved like the meter provider, except that an explicit + `NoOpLoggerProvider` global is an operator opt-out that builds no recorder at + all. The deprecated `error.*` span attributes and the `exception` span event + are still stamped by the emitter for backwards compatibility. ### Adapter diff --git a/litellm/integrations/otel/__init__.py b/litellm/integrations/otel/__init__.py index 7f78f7156b4..5e167e006ff 100644 --- a/litellm/integrations/otel/__init__.py +++ b/litellm/integrations/otel/__init__.py @@ -52,6 +52,7 @@ from litellm.integrations.otel.model.semconv import ( GenAIProvider, JsonRpc, LiteLLM, + LiteLLMError, MCPMethod, Metric, Network, @@ -87,6 +88,7 @@ __all__ = [ "HTTP", "JsonRpc", "LiteLLM", + "LiteLLMError", "MCP", "MCPMethod", "Metric", diff --git a/litellm/integrations/otel/emitter.py b/litellm/integrations/otel/emitter.py index 9d686cb53fd..bd80dbfbc8f 100644 --- a/litellm/integrations/otel/emitter.py +++ b/litellm/integrations/otel/emitter.py @@ -16,9 +16,11 @@ from litellm.integrations.otel.model.payloads import ( MCPListToolsSpanData, MCPToolCallSpanData, ServiceSpanData, + SpanError, ) +from litellm.integrations.otel.plumbing.events import GenAIEventRecorder from litellm.integrations.otel.plumbing.providers import to_otel_span_kind -from litellm.integrations.otel.model.semconv import Error, ExceptionEvent +from litellm.integrations.otel.model.semconv import Error, ExceptionEvent, LiteLLMError from litellm.integrations.otel.model.spans import ( SPAN_REGISTRY, SpanRole, @@ -49,15 +51,38 @@ _NAME_BUILDERS: dict[SpanRole, Callable[..., str]] = { _DEDUP_CACHE_MAX = 10_000 +def _stamp_otel_error_attributes(span: Span, error_type: str, resolved_message: str) -> None: + """Stamp the OTel-semconv error attributes (``error.type`` + ``error.message``). + ``error_type`` and ``resolved_message`` are ``finish_span``'s already-computed + fallback chains, so the pair on the status, event, and attributes stays in + lockstep.""" + span.set_attribute(Error.TYPE, error_type) + span.set_attribute(Error.MESSAGE, resolved_message) + + +def _stamp_litellm_error_attributes(span: Span, error: SpanError) -> None: + """Stamp litellm-specific error detail attributes. Emitted only when the + corresponding field is populated so guardrail-shape errors carrying only a + message aren't polluted with empty detail keys.""" + if error.code: + span.set_attribute(LiteLLMError.CODE, error.code) + if error.stack_trace: + span.set_attribute(LiteLLMError.STACK_TRACE, error.stack_trace) + if error.llm_provider: + span.set_attribute(LiteLLMError.LLM_PROVIDER, error.llm_provider) + + class SpanEmitter: def __init__( self, tracer: Tracer, config: OpenTelemetryV2Config, mappers: Sequence[AttributeMapper] | None = None, + event_recorder: GenAIEventRecorder | None = None, ) -> None: self._tracer = tracer self._config = config + self._event_recorder = event_recorder # The mapper chain is the sole source of span attributes. When not # passed in, resolve it from the config so there's one source of truth. self._mappers: list[AttributeMapper] = ( @@ -227,16 +252,25 @@ class SpanEmitter: if error and (error.error_type or error.message): error_type = error.error_type or "error" message = error.message or error.error_type or "error" - span.set_attribute(Error.TYPE, error_type) + _stamp_otel_error_attributes(span, error_type, message) + _stamp_litellm_error_attributes(span, error) span.set_status(Status(StatusCode.ERROR, message)) - # Carry the full message on the standard ``exception`` event so backends - # map it as full text under ``exception.message``. Setting it as a bare - # string attribute instead lets backends like Elasticsearch dynamic-map - # it to a ``keyword`` capped at 1024 chars, truncating the message. + # Also emit the semconv ``exception`` event so backends that + # dynamic-map unknown string span attrs to ``keyword`` (e.g. + # Elasticsearch with a 1024-char ``ignore_above``) still see the + # full untruncated message on the recognized event field. span.add_event( ExceptionEvent.NAME, {ExceptionEvent.TYPE: error_type, ExceptionEvent.MESSAGE: message}, ) + if self._event_recorder is not None and role is SpanRole.LLM_CALL: + self._event_recorder.record_operation_exception( + span_context=span.get_span_context(), + error_type=error_type, + message=message, + stack_trace=error.stack_trace, + timestamp_ns=end_time_ns, + ) # On success leave the status UNSET (the semconv default) rather than # forcing OK — that matches the FastAPI server span and avoids implying a # span-level health signal litellm doesn't actually evaluate. Only a diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 51b673557de..978293a4e19 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -6,6 +6,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Callable, Iterator, Mapping, Sequence, cast from opentelemetry.context import Context, attach, get_current +from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk.trace import TracerProvider from opentelemetry.trace import Span, Tracer, get_current_span, use_span @@ -40,14 +41,17 @@ from litellm.integrations.otel.model.payloads import ( is_mcp_list_tools, is_mcp_tool_call, ) +from litellm.integrations.otel.plumbing.events import GenAIEventRecorder from litellm.integrations.otel.plumbing.metrics import ( GenAIMetricRecorder, create_genai_metrics, ) from litellm.integrations.otel.plumbing.providers import ( build_tracer_provider, + get_event_logger, get_meter, get_tracer, + resolve_logger_provider, resolve_meter_provider, ) from litellm.integrations.otel.plumbing.routing import TenantTracerCache @@ -106,7 +110,7 @@ class OpenTelemetryV2(CustomLogger): config: OpenTelemetryV2Config | None = None, callback_name: str | None = None, tracer_provider: TracerProvider | None = None, - logger_provider: Any | None = None, # reserved for OTel logs + logger_provider: LoggerProvider | None = None, meter_provider: Any | None = None, **kwargs: Any, ) -> None: @@ -125,7 +129,12 @@ class OpenTelemetryV2(CustomLogger): self.tracer: Tracer = get_tracer(self._tracer_provider, LITELLM_TRACER_NAME) self._metrics_recorder = self._init_metrics(meter_provider) self._metric_filter_error_logged = False - self._emitter = SpanEmitter(self.tracer, self.config, mappers=resolve_mappers(self.config.mapper_names)) + self._emitter = SpanEmitter( + self.tracer, + self.config, + mappers=resolve_mappers(self.config.mapper_names), + event_recorder=self._init_events(logger_provider), + ) self._tenant_tracers = TenantTracerCache(self.config, callback_name, LITELLM_TRACER_NAME) self._open_llm_calls: "OrderedDict[str, _LLMCallSpan]" = OrderedDict() # call_ids for which the LLM-call span has already been emitted; lets @@ -149,6 +158,22 @@ class OpenTelemetryV2(CustomLogger): meter = get_meter(provider, LITELLM_TRACER_NAME) return GenAIMetricRecorder(create_genai_metrics(meter), self.callback_name) + def _init_events(self, logger_provider: LoggerProvider | None) -> "GenAIEventRecorder | None": + """Create the GenAI event recorder when events are enabled, else ``None``. + + ``logger_provider`` is an explicit override (tests inject one); otherwise the + provider is resolved from the OTel global so an operator-configured logs + pipeline receives the events, building and registering one only when no + global provider is set. A ``None`` resolution means the operator opted out + of the logs signal, so no recorder is built. + """ + if not self.config.enable_events: + return None + provider = resolve_logger_provider(self.config, logger_provider) + if provider is None: + return None + return GenAIEventRecorder(get_event_logger(provider, LITELLM_TRACER_NAME)) + # ====================================================================== # # Proxy global registration # ====================================================================== # @@ -416,7 +441,11 @@ class OpenTelemetryV2(CustomLogger): span.end(end_time=end_time_ns) return None - data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=self.config.capture_span_content) + data = LLMCallSpanData.from_standard_logging_payload( + payload, + capture_content=self.config.capture_span_content, + time_to_first_chunk_seconds=call.time_to_first_chunk_seconds, + ) if carrier.spans: for span in carrier.spans: self._emitter.finish_span(SpanRole.LLM_CALL, span, data, end_time_ns=end_time_ns) diff --git a/litellm/integrations/otel/mappers/genai.py b/litellm/integrations/otel/mappers/genai.py index c5d8c35de7d..f568afa9e3e 100644 --- a/litellm/integrations/otel/mappers/genai.py +++ b/litellm/integrations/otel/mappers/genai.py @@ -55,6 +55,7 @@ class GenAIMapper: GenAI.RESPONSE_MODEL: lambda d: d.response_model, GenAI.RESPONSE_ID: lambda d: d.response_id, GenAI.RESPONSE_FINISH_REASONS: lambda d: list(d.finish_reasons) if d.finish_reasons else None, + GenAI.RESPONSE_TIME_TO_FIRST_CHUNK: lambda d: d.time_to_first_chunk_seconds, GenAI.USAGE_INPUT_TOKENS: lambda d: d.usage.input_tokens, GenAI.USAGE_OUTPUT_TOKENS: lambda d: d.usage.output_tokens, Error.TYPE: lambda d: d.error.error_type if d.error else None, diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py index 93a83b669fb..68d4cf5186b 100644 --- a/litellm/integrations/otel/model/metadata.py +++ b/litellm/integrations/otel/model/metadata.py @@ -43,7 +43,7 @@ from typing import TYPE_CHECKING, Any, Mapping, cast from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL from litellm.integrations.otel.model.destination import OtelDestination from litellm.integrations.otel.model.semconv import resolve_operation -from litellm.integrations.otel.model.utils import as_str +from litellm.integrations.otel.model.utils import as_str, to_seconds from litellm.integrations.otel.plumbing.context import request_destinations if TYPE_CHECKING: @@ -209,6 +209,7 @@ class LLMCallEvent: # span is renamed from the typed payload at close (``finish_span``); this only # needs to be reasonable for a span that never gets closed (a leak). provisional_span_name: str + time_to_first_chunk_seconds: float | None @classmethod def from_dict(cls, kwargs: Mapping[str, Any]) -> "LLMCallEvent": @@ -224,9 +225,25 @@ class LLMCallEvent: otel_destinations=request_destinations(), is_no_upstream_call=bool(kwargs.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)), provisional_span_name=f"{operation.value} {model}".strip(), + time_to_first_chunk_seconds=time_to_first_chunk_seconds(kwargs), ) +def time_to_first_chunk_seconds(kwargs: Mapping[str, Any]) -> float | None: + """Seconds from the upstream request being issued (``api_call_start_time``) + to the first streamed chunk (``completion_start_time``); ``None`` for + non-streaming calls, where ``completion_start_time`` is backfilled with the + end time and would not measure first-chunk latency.""" + optional_params = cast(Mapping[str, Any], kwargs.get("optional_params") or {}) + if not optional_params.get("stream"): + return None + api_call_start = to_seconds(kwargs.get("api_call_start_time")) + completion_start = to_seconds(kwargs.get("completion_start_time")) + if api_call_start is None or completion_start is None: + return None + return completion_start - api_call_start + + def _call_id(payload: "StandardLoggingPayload | None", kwargs: Mapping[str, Any]) -> str | None: """The call id from the payload (when closed) or the bare kwargs (at pre_call).""" if payload is not None: diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index b0dcf97b787..4a8f01858b5 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -141,6 +141,9 @@ class LLMCost: class SpanError: error_type: str | None = None message: str | None = None + code: str | None = None + stack_trace: str | None = None + llm_provider: str | None = None @dataclass(frozen=True) @@ -305,10 +308,14 @@ class LLMCallSpanData: messages_in: tuple[Mapping[str, object], ...] = () choices_out: tuple[Mapping[str, object], ...] = () system_fingerprint: str | None = None + time_to_first_chunk_seconds: float | None = None @classmethod def from_standard_logging_payload( - cls, payload: "StandardLoggingPayload", capture_content: bool = False + cls, + payload: "StandardLoggingPayload", + capture_content: bool = False, + time_to_first_chunk_seconds: float | None = None, ) -> "LLMCallSpanData": params = cast(Mapping[str, object], payload.get("model_parameters") or {}) # The single parse of the request's metadata — the request-vs-provider @@ -349,6 +356,7 @@ class LLMCallSpanData: messages_in=_dicts(payload.get("messages")) if capture_content else (), choices_out=choices_out if capture_content else (), system_fingerprint=as_str(response.get("system_fingerprint")), + time_to_first_chunk_seconds=time_to_first_chunk_seconds, ) @@ -566,6 +574,9 @@ def _parse_error(payload: "StandardLoggingPayload") -> SpanError | None: return SpanError( error_type=as_str(info.get("error_class")) or as_str(info.get("error_code")), message=as_str(info.get("error_message")) or as_str(payload.get("error_str")), + code=as_str(info.get("error_code")), + stack_trace=as_str(info.get("traceback")), + llm_provider=as_str(info.get("llm_provider")), ) diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py index 6315a5a4a89..44b2f7e0488 100644 --- a/litellm/integrations/otel/model/semconv.py +++ b/litellm/integrations/otel/model/semconv.py @@ -69,6 +69,7 @@ class GenAI: RESPONSE_ID: Final = "gen_ai.response.id" RESPONSE_MODEL: Final = "gen_ai.response.model" RESPONSE_FINISH_REASONS: Final = "gen_ai.response.finish_reasons" + RESPONSE_TIME_TO_FIRST_CHUNK: Final = "gen_ai.response.time_to_first_chunk" # usage USAGE_INPUT_TOKENS: Final = "gen_ai.usage.input_tokens" USAGE_OUTPUT_TOKENS: Final = "gen_ai.usage.output_tokens" @@ -143,7 +144,24 @@ class Client: class Error: + """OTel-defined error attribute keys, from the semconv ``error.*`` registry. + ``MESSAGE`` is marked *Deprecated* upstream in favor of domain-specific + error message keys plus ``exception.message`` on the exception event, but + litellm still stamps it.""" + TYPE: Final = "error.type" + MESSAGE: Final = "error.message" + + +class LiteLLMError: + """Detail keys for the mapped provider exception of a failed LLM call. + OTel semconv does not define these, so they live under the ``litellm.*`` + vendor namespace rather than squatting on the semconv-owned ``error.*`` + namespace.""" + + CODE: Final = "litellm.provider.error.code" + STACK_TRACE: Final = "litellm.provider.error.stack_trace" + LLM_PROVIDER: Final = "litellm.provider.error.llm_provider" class ExceptionEvent: @@ -159,6 +177,19 @@ class ExceptionEvent: NAME: Final = "exception" TYPE: Final = "exception.type" MESSAGE: Final = "exception.message" + STACKTRACE: Final = "exception.stacktrace" + + +class GenAIEvent: + """GenAI semconv event names, from the GenAI registry's *events* section. + + ``gen_ai.client.operation.exception`` is defined as a log-based event + (severity WARN) carrying the ``exception.*`` trio, correlated to the failed + span via the trace/span ids — the semconv-compliant home for GenAI failure + details, unlike the deprecated ``error.message`` span attribute. + """ + + OPERATION_EXCEPTION: Final = "gen_ai.client.operation.exception" class Server: diff --git a/litellm/integrations/otel/plumbing/events.py b/litellm/integrations/otel/plumbing/events.py new file mode 100644 index 00000000000..f674526d04f --- /dev/null +++ b/litellm/integrations/otel/plumbing/events.py @@ -0,0 +1,52 @@ +"""GenAI client events: the ``gen_ai.client.operation.exception`` log event. + +The GenAI semantic conventions define exception recording for client +operations as a log-based event (severity WARN) carrying the ``exception.*`` +attribute trio, correlated to the failed span through the trace/span ids — +not as a span attribute or span event. This module owns building and +emitting that event; the exporter pipeline it rides is built in +:mod:`litellm.integrations.otel.plumbing.providers`. +""" + +from dataclasses import dataclass + +from opentelemetry._events import Event, EventLogger +from opentelemetry._logs.severity import SeverityNumber +from opentelemetry.trace import SpanContext + +from litellm.integrations.otel.model.semconv import ExceptionEvent, GenAIEvent + + +@dataclass(frozen=True, slots=True) +class GenAIEventRecorder: + event_logger: EventLogger + + def record_operation_exception( + self, + span_context: SpanContext, + error_type: str, + message: str, + stack_trace: str | None, + timestamp_ns: int | None, + ) -> None: + # ``exception.type`` and ``exception.message`` are the semconv-required + # pair and always ride the event; only the recommended stacktrace is + # conditional on the payload carrying one. + stacktrace = ((ExceptionEvent.STACKTRACE, stack_trace),) if stack_trace else () + self.event_logger.emit( + Event( + name=GenAIEvent.OPERATION_EXCEPTION, + timestamp=timestamp_ns, + trace_id=span_context.trace_id, + span_id=span_context.span_id, + trace_flags=span_context.trace_flags, + severity_number=SeverityNumber.WARN, + attributes=dict( + ( + (ExceptionEvent.TYPE, error_type), + (ExceptionEvent.MESSAGE, message), + *stacktrace, + ) + ), + ) + ) diff --git a/litellm/integrations/otel/plumbing/metrics.py b/litellm/integrations/otel/plumbing/metrics.py index cb1f9214876..50d0fb75962 100644 --- a/litellm/integrations/otel/plumbing/metrics.py +++ b/litellm/integrations/otel/plumbing/metrics.py @@ -21,6 +21,7 @@ from litellm.integrations.opentelemetry import ( _build_metric_attribute_filter, _resolve_metric_attribute_filter, ) +from litellm.integrations.otel.model.metadata import time_to_first_chunk_seconds from litellm.integrations.otel.model.semconv import Metric, resolve_operation from litellm.integrations.otel.model.utils import to_seconds from litellm.litellm_core_utils.safe_json_dumps import safe_dumps @@ -181,13 +182,10 @@ class GenAIMetricRecorder: self._metrics.token_usage.record(usage.get("completion_tokens", 0), attributes=out_attrs) def _record_time_to_first_token(self, kwargs: Mapping[str, Any], common_attrs: dict) -> None: - if not kwargs.get("optional_params", {}).get("stream", False): + time_to_first_chunk = time_to_first_chunk_seconds(kwargs) + if time_to_first_chunk is None: return - api_call_start = to_seconds(kwargs.get("api_call_start_time")) - completion_start = to_seconds(kwargs.get("completion_start_time")) - if api_call_start is None or completion_start is None: - return - self._metrics.time_to_first_token.record(completion_start - api_call_start, attributes=common_attrs) + self._metrics.time_to_first_token.record(time_to_first_chunk, attributes=common_attrs) def _record_time_per_output_token( self, diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index 71eb6aef5aa..f6733527eb6 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -2,9 +2,20 @@ from typing import TYPE_CHECKING, Any, Callable, Iterable -from opentelemetry import baggage, metrics +from opentelemetry import _logs, baggage, metrics +from opentelemetry._events import EventLogger +from opentelemetry._logs import LoggerProvider, NoOpLoggerProvider from opentelemetry.context import Context from opentelemetry.metrics import MeterProvider, NoOpMeterProvider +from opentelemetry.sdk._events import EventLoggerProvider +from opentelemetry.sdk._logs import LoggerProvider as SDKLoggerProvider +from opentelemetry.sdk._logs.export import ( + BatchLogRecordProcessor, + ConsoleLogExporter, + InMemoryLogExporter, + LogExporter, + SimpleLogRecordProcessor, +) from opentelemetry.sdk.metrics import MeterProvider as SDKMeterProvider from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider @@ -260,6 +271,112 @@ def build_metric_reader(config: OpenTelemetryV2Config) -> "MetricReader": return PeriodicExportingMetricReader(exporter, export_interval_millis=5000) +def _otlp_logs_endpoint(endpoint: str | None) -> str | None: + """Point an OTLP/HTTP base endpoint at the ``/v1/logs`` signal path. + + The OTLP/HTTP exporter only appends ``/v1/logs`` when it reads + ``OTEL_EXPORTER_OTLP_ENDPOINT`` itself; an explicitly passed endpoint is used + verbatim, so a base URL would POST to the root. Mirror ``_otlp_traces_endpoint`` + for the logs signal (rewriting a sibling signal path when present). + """ + if not endpoint: + return endpoint + endpoint = endpoint.rstrip("/") + if endpoint.endswith("/v1/logs"): + return endpoint + for other_signal in ("/v1/traces", "/v1/metrics"): + if endpoint.endswith(other_signal): + return endpoint[: -len(other_signal)] + "/v1/logs" + return endpoint + "/v1/logs" + + +def build_log_exporter(config: OpenTelemetryV2Config) -> LogExporter: + """Build a log exporter mirroring the exporter selection of the other signals. + + ``console`` (and any unrecognized kind) exports to the console; ``otlp_http`` + and ``otlp_grpc`` export over OTLP with the configured endpoint/headers; + ``in_memory`` buffers for tests. Like GenAI metrics, events ride the + single-destination shorthand fields, not the multi-exporter ``exporters`` list. + """ + kind = (config.exporter or "console").lower() + if kind in ("in_memory", "inmemory", "memory"): + return InMemoryLogExporter() + if kind in ("otlp_http", "http", "http/protobuf", "http/json"): + from opentelemetry.exporter.otlp.proto.http._log_exporter import ( + OTLPLogExporter as HTTPLogExporter, + ) + + return HTTPLogExporter( + endpoint=_otlp_logs_endpoint(config.endpoint), + headers=parse_headers(config.headers), + ) + if kind in ("otlp_grpc", "grpc"): + try: + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter as GRPCLogExporter, + ) + except ImportError as exc: + raise ImportError( + "OpenTelemetry OTLP gRPC log exporter is not available. Install " + "`opentelemetry-exporter-otlp` and `grpcio` (or `litellm[grpc]`)." + ) from exc + + return GRPCLogExporter(endpoint=config.endpoint, headers=parse_headers(config.headers)) + return ConsoleLogExporter() + + +def build_logger_provider( + config: OpenTelemetryV2Config, + log_exporter: LogExporter | None = None, +) -> SDKLoggerProvider: + """Build the :class:`LoggerProvider` GenAI events export through. + + ``log_exporter`` is an explicit override (tests inject an + ``InMemoryLogExporter``); otherwise the exporter is selected from the config's + exporter kind via :func:`build_log_exporter`. Console and in-memory exporters + get a Simple processor (synchronous export, which tests rely on), everything + else a Batch processor — the same split as span processing. + """ + exporter = log_exporter if log_exporter is not None else build_log_exporter(config) + provider = SDKLoggerProvider(resource=build_resource(config)) + use_simple = isinstance(exporter, (ConsoleLogExporter, InMemoryLogExporter)) + provider.add_log_record_processor( + SimpleLogRecordProcessor(exporter) if use_simple else BatchLogRecordProcessor(exporter) + ) + return provider + + +def resolve_logger_provider( + config: OpenTelemetryV2Config, + logger_provider: SDKLoggerProvider | None = None, +) -> SDKLoggerProvider | None: + """Resolve the :class:`LoggerProvider` GenAI events record through, or ``None`` + when the operator has opted out of the logs signal. + + Same resolution order as :func:`resolve_meter_provider`: an injected provider + wins (DI/tests); an operator-configured SDK global is reused so events ride + their pipeline; an explicit ``NoOpLoggerProvider`` global is an opt-out and + yields ``None``, so no event is ever built. Only the default placeholder + global makes V2 build a provider from the config and publish it as the global. + """ + if logger_provider is not None: + return logger_provider + + existing: LoggerProvider = _logs.get_logger_provider() + if isinstance(existing, SDKLoggerProvider): + return existing + if isinstance(existing, NoOpLoggerProvider): + return None + + provider = build_logger_provider(config) + _logs.set_logger_provider(provider) + return provider + + +def get_event_logger(provider: SDKLoggerProvider, name: str = "litellm") -> EventLogger: + return EventLoggerProvider(logger_provider=provider).get_event_logger(name, litellm_version) + + def build_meter_provider( config: OpenTelemetryV2Config, metric_reader: "MetricReader | None" = None, diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 4ebe312e301..64d4dd578b2 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio +import math import os import sys from datetime import datetime, timedelta @@ -65,6 +66,26 @@ if TYPE_CHECKING: else: AsyncIOScheduler = Any +_DEFAULT_BUDGET_METRICS_PER_REQUEST_TIMEOUT = 5.0 + + +def _get_budget_metrics_per_request_timeout() -> float: + raw = os.getenv("PROMETHEUS_BUDGET_METRICS_PER_REQUEST_TIMEOUT") + if raw is None: + return _DEFAULT_BUDGET_METRICS_PER_REQUEST_TIMEOUT + try: + parsed = float(raw) + except ValueError: + parsed = None + if parsed is None or not math.isfinite(parsed) or parsed <= 0: + verbose_logger.debug( + "[Non-Blocking] Prometheus: invalid PROMETHEUS_BUDGET_METRICS_PER_REQUEST_TIMEOUT=%r; using default %ss.", + raw, + _DEFAULT_BUDGET_METRICS_PER_REQUEST_TIMEOUT, + ) + return _DEFAULT_BUDGET_METRICS_PER_REQUEST_TIMEOUT + return parsed + class PrometheusLogger(CustomLogger): # Class variables or attributes @@ -218,6 +239,18 @@ class PrometheusLogger(CustomLogger): labelnames=self.get_labels_for_metric("litellm_output_audio_tokens_metric"), ) + self.litellm_video_duration_seconds_metric = self._counter_factory( + "litellm_video_duration_seconds_metric", + "Seconds of video generated, from usage.duration_seconds on video generation calls", + labelnames=self.get_labels_for_metric("litellm_video_duration_seconds_metric"), + ) + + self.litellm_images_generated_metric = self._counter_factory( + "litellm_images_generated_metric", + "Number of images generated, from the image generation response", + labelnames=self.get_labels_for_metric("litellm_images_generated_metric"), + ) + # Remaining Budget for Team self.litellm_remaining_team_budget_metric = self._gauge_factory( "litellm_remaining_team_budget_metric", @@ -1315,6 +1348,12 @@ class PrometheusLogger(CustomLogger): label_context=label_context, ) + self._increment_media_generation_metrics( + standard_logging_payload=standard_logging_payload, + enum_values=enum_values, + label_context=label_context, + ) + # MCP tool call metrics self._increment_mcp_tool_call_metrics( standard_logging_payload=standard_logging_payload, @@ -1438,8 +1477,65 @@ class PrometheusLogger(CustomLogger): ), ] - for counter, metric_name, value in detail_metrics: - if not isinstance(value, (int, float)) or value <= 0: + PrometheusLogger._inc_sparse_usage_counters( + self, + detail_metrics, + enum_values=enum_values, + label_context=label_context, + ) + + def _increment_media_generation_metrics( + self, + standard_logging_payload: StandardLoggingPayload, + enum_values: UserAPIKeyLabelValues, + label_context: PrometheusLabelFactoryContext | None = None, + ) -> None: + """ + Increment video-seconds and images-generated counters from + ``standard_logging_payload["metadata"]["usage_object"]``. Video + providers report ``duration_seconds`` there; image generation calls + report ``output_image_count``. Both are sparse: only emitted when the + value is present and > 0, so token-only call types are unaffected. + """ + metadata = standard_logging_payload.get("metadata") or {} + usage_object = metadata.get("usage_object") if isinstance(metadata, dict) else None + if not isinstance(usage_object, dict): + return + + media_metrics: list[tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]] = [ + ( + self.litellm_video_duration_seconds_metric, + "litellm_video_duration_seconds_metric", + usage_object.get("duration_seconds"), + ), + ( + self.litellm_images_generated_metric, + "litellm_images_generated_metric", + usage_object.get("output_image_count"), + ), + ] + + PrometheusLogger._inc_sparse_usage_counters( + self, + media_metrics, + enum_values=enum_values, + label_context=label_context, + ) + + def _inc_sparse_usage_counters( + self, + counters_with_values: list[tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]], + enum_values: UserAPIKeyLabelValues, + label_context: PrometheusLabelFactoryContext | None = None, + ) -> None: + """ + Increment each ``(counter, metric_name, value)`` entry whose value is + a positive number. Non-numeric values (including booleans from + malformed provider usage dicts) and values <= 0 are skipped, keeping + scrape output sparse. + """ + for counter, metric_name, value in counters_with_values: + if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0: continue PrometheusLogger._inc_labeled_counter( self, @@ -1597,6 +1693,14 @@ class PrometheusLogger(CustomLogger): user_id: Optional[str] = None, user_api_key_org_id: Optional[str] = None, ): + if ( + isinstance(self.litellm_remaining_team_budget_metric, NoOpMetric) + and isinstance(self.litellm_remaining_api_key_budget_metric, NoOpMetric) + and isinstance(self.litellm_remaining_user_budget_metric, NoOpMetric) + and isinstance(self.litellm_remaining_org_budget_metric, NoOpMetric) + ): + return + _metadata = litellm_params.get("metadata") or {} _team_spend = _metadata.get("user_api_key_team_spend", None) _team_max_budget = _metadata.get("user_api_key_team_max_budget", None) @@ -1607,7 +1711,15 @@ class PrometheusLogger(CustomLogger): _user_spend = _metadata.get("user_api_key_user_spend", None) _user_max_budget = _metadata.get("user_api_key_user_max_budget", None) - results = await asyncio.gather( + # Bound the per-request budget-metric emission so that slow Redis/DB + # lookups under load cannot consume the whole LoggingWorker watchdog + # (LOGGING_WORKER_MAX_TIME_PER_COROUTINE, default 20s) and get the entire + # success-logging event cancelled. Budget gauges are also refreshed by the + # periodic cron every PROMETHEUS_BUDGET_METRICS_REFRESH_INTERVAL_MINUTES, + # so dropping one slow per-request emission only loses sub-cron real-time + # detail, not correctness. + budget_metrics_timeout = _get_budget_metrics_per_request_timeout() + gather_coro = asyncio.gather( self._set_api_key_budget_metrics_after_api_request( user_api_key=user_api_key, user_api_key_alias=user_api_key_alias, @@ -1634,6 +1746,16 @@ class PrometheusLogger(CustomLogger): ), return_exceptions=True, ) + try: + results = await asyncio.wait_for(gather_coro, timeout=budget_metrics_timeout) + except asyncio.TimeoutError: + verbose_logger.debug( + "[Non-Blocking] Prometheus: per-request budget metric emission " + "exceeded %ss under load; skipping (values are refreshed by the " + "periodic budget-metrics cron job).", + budget_metrics_timeout, + ) + return for i, r in enumerate(results): if isinstance(r, Exception): verbose_logger.debug( @@ -1669,6 +1791,35 @@ class PrometheusLogger(CustomLogger): amount=float(response_cost), ) + @staticmethod + def _get_remaining_from_v3_rate_limit_headers( + standard_logging_payload: StandardLoggingPayload | None, + rate_limit_type: Literal["requests", "tokens"], + ) -> int | None: + """ + Read the per-(key, model) remaining value emitted by the v3 rate + limiter (``parallel_request_limiter_v3.py``), which writes + ``x-ratelimit-model_per_key-remaining-{requests,tokens}`` into + ``standard_logging_object.hidden_params.additional_headers`` instead + of the ``litellm-key-remaining-*`` metadata keys the legacy limiter + sets. The header carries no model group; it always refers to this + request's model group, which is what the gauges are labeled with. + Values are written in-process as plain ints (never HTTP-serialized + strings), so anything else is rejected rather than coerced. + """ + if standard_logging_payload is None: + return None + hidden_params = standard_logging_payload.get("hidden_params") + if hidden_params is None: + return None + additional_headers = hidden_params.get("additional_headers") + if additional_headers is None: + return None + value = dict(additional_headers).get(f"x-ratelimit-model_per_key-remaining-{rate_limit_type}") + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + def _set_virtual_key_rate_limit_metrics( self, user_api_key: Optional[str], @@ -1686,11 +1837,20 @@ class PrometheusLogger(CustomLogger): model_group = get_model_group_from_litellm_kwargs(kwargs) remaining_requests_variable_name = f"litellm-key-remaining-requests-{model_group}" remaining_tokens_variable_name = f"litellm-key-remaining-tokens-{model_group}" + standard_logging_payload: StandardLoggingPayload | None = kwargs.get("standard_logging_object") remaining_requests = metadata.get(remaining_requests_variable_name) + if remaining_requests is None: + remaining_requests = self._get_remaining_from_v3_rate_limit_headers( + standard_logging_payload=standard_logging_payload, rate_limit_type="requests" + ) if remaining_requests is None: remaining_requests = sys.maxsize remaining_tokens = metadata.get(remaining_tokens_variable_name) + if remaining_tokens is None: + remaining_tokens = self._get_remaining_from_v3_rate_limit_headers( + standard_logging_payload=standard_logging_payload, rate_limit_type="tokens" + ) if remaining_tokens is None: remaining_tokens = sys.maxsize @@ -2004,6 +2164,43 @@ class PrometheusLogger(CustomLogger): return False + @staticmethod + def _extract_api_provider_from_request_data(request_data: dict) -> Optional[str]: + """ + Best-effort provider for the client-side failure path. + + A request can fail before a deployment is resolved, so the provider is + not always known. Prefer the resolved ``custom_llm_provider`` on + ``litellm_params``, then any provider recovered onto a partial + ``standard_logging_object`` (e.g. a stream that broke mid-flight), and + finally infer it from the requested model name (e.g. ``gpt-4o-mini`` -> + ``openai``) since the proxy's failure ``request_data`` usually carries + only the client-supplied model. Return ``None`` when it cannot be + determined so the label emits empty rather than a guess. + """ + litellm_params = request_data.get("litellm_params") or {} + provider = litellm_params.get("custom_llm_provider") + if provider: + return provider + standard_logging_object = request_data.get("standard_logging_object") or {} + provider = standard_logging_object.get("custom_llm_provider") + if provider: + return provider + model = litellm_params.get("model") or request_data.get("model") + if not model: + return None + try: + return litellm.get_llm_provider(model=model)[1] or None + except litellm.exceptions.BadRequestError: + return None + except Exception as e: # noqa: BLE001 - metrics labeling must never break request/failure handling + verbose_logger.debug( + "prometheus: unexpected error inferring api_provider from model=%s: %s", + model, + e, + ) + return None + async def async_post_call_failure_hook( self, request_data: dict, @@ -2039,6 +2236,7 @@ class PrometheusLogger(CustomLogger): _metadata = request_data.get("metadata", {}) or {} model_id = _metadata.get("model_info", {}).get("id") or request_data.get("model_info", {}).get("id") rate_limit_category, rate_limit_type = self._extract_rate_limit_labels(original_exception) + api_provider = self._extract_api_provider_from_request_data(request_data) enum_values = UserAPIKeyLabelValues( end_user=user_api_key_dict.end_user_id, user=user_api_key_dict.user_id, @@ -2060,6 +2258,7 @@ class PrometheusLogger(CustomLogger): client_ip=_metadata.get("requester_ip_address"), user_agent=_metadata.get("user_agent"), model_id=model_id, + api_provider=api_provider, stream=(str(request_data.get("stream")) if litellm.prometheus_emit_stream_label else None), ) _label_ctx = PrometheusLabelFactoryContext(enum_values) @@ -3254,6 +3453,9 @@ class PrometheusLogger(CustomLogger): - looks up team info from db if not available in metadata - Set team budget metrics """ + if isinstance(self.litellm_remaining_team_budget_metric, NoOpMetric): + return + if user_api_team: team_object = await self._assemble_team_object( team_id=user_api_team, @@ -3375,6 +3577,9 @@ class PrometheusLogger(CustomLogger): - Fetches org info via cache (get_org_object) - Sets org budget metrics """ + if isinstance(self.litellm_remaining_org_budget_metric, NoOpMetric): + return + if not org_id: return @@ -3504,6 +3709,9 @@ class PrometheusLogger(CustomLogger): key_max_budget: Optional[float], key_spend: Optional[float], ): + if isinstance(self.litellm_remaining_api_key_budget_metric, NoOpMetric): + return + if user_api_key: user_api_key_dict = await self._assemble_key_object( user_api_key=user_api_key, @@ -3541,6 +3749,7 @@ class PrometheusLogger(CustomLogger): hashed_token=user_api_key_dict.token, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, + check_cache_only=True, ) if key_object: user_api_key_dict.budget_reset_at = key_object.budget_reset_at @@ -3563,6 +3772,9 @@ class PrometheusLogger(CustomLogger): - looks up user info from db if not available in metadata - Set user budget metrics """ + if isinstance(self.litellm_remaining_user_budget_metric, NoOpMetric): + return + if user_id: user_object = await self._assemble_user_object( user_id=user_id, diff --git a/litellm/integrations/rubrik.py b/litellm/integrations/rubrik.py index 2b54a411ec7..11809ee6361 100644 --- a/litellm/integrations/rubrik.py +++ b/litellm/integrations/rubrik.py @@ -7,7 +7,7 @@ import time import urllib.parse import uuid from collections import Counter -from typing import TYPE_CHECKING, Any, Literal, Optional +from typing import TYPE_CHECKING, Any, List, Literal, Optional import httpx from litellm._logging import verbose_logger @@ -52,6 +52,10 @@ class _MalformedToolBlockingResponseError(Exception): class RubrikLogger(CustomGuardrail, CustomBatchLogger): + @classmethod + def get_supported_event_hooks(cls) -> List[GuardrailEventHooks]: + return [GuardrailEventHooks.pre_call, GuardrailEventHooks.post_call] + def __init__( self, api_key: str | None = None, @@ -69,6 +73,7 @@ class RubrikLogger(CustomGuardrail, CustomBatchLogger): kwargs["event_hook"] = kwargs.get("event_hook") or GuardrailEventHooks.post_call if kwargs.get("default_on") is None: kwargs["default_on"] = True + kwargs.setdefault("supported_event_hooks", list(self.get_supported_event_hooks())) super().__init__( flush_lock=self.flush_lock, **kwargs, diff --git a/litellm/integrations/s3.py b/litellm/integrations/s3.py index 53a982cd2c4..e8252d87572 100644 --- a/litellm/integrations/s3.py +++ b/litellm/integrations/s3.py @@ -161,8 +161,13 @@ def get_s3_object_key( start_time: datetime, s3_file_name: str, ) -> str: + sanitized_s3_file_name = s3_file_name.replace("/", "_") s3_object_key = ( - (s3_path.rstrip("/") + "/" if s3_path else "") + prefix + start_time.strftime("%Y-%m-%d") + "/" + s3_file_name + (s3_path.rstrip("/") + "/" if s3_path else "") + + prefix + + start_time.strftime("%Y-%m-%d") + + "/" + + sanitized_s3_file_name ) # we need the s3 key to include the time, so we log cache hits too s3_object_key += ".json" return s3_object_key diff --git a/litellm/integrations/s3_v2.py b/litellm/integrations/s3_v2.py index 939289f96ea..5b953035cfd 100644 --- a/litellm/integrations/s3_v2.py +++ b/litellm/integrations/s3_v2.py @@ -54,6 +54,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_strip_base64_files: bool = False, s3_use_key_prefix: bool = False, s3_use_virtual_hosted_style: bool = False, + s3_server_side_encryption: Optional[str] = None, s3_callback_params_override: Optional[dict] = None, **kwargs, ): @@ -92,6 +93,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_strip_base64_files=s3_strip_base64_files, s3_use_key_prefix=s3_use_key_prefix, s3_use_virtual_hosted_style=s3_use_virtual_hosted_style, + s3_server_side_encryption=s3_server_side_encryption, ) verbose_logger.debug(f"s3 logger using endpoint url {s3_endpoint_url}") @@ -145,6 +147,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_strip_base64_files: bool = False, s3_use_key_prefix: bool = False, s3_use_virtual_hosted_style: bool = False, + s3_server_side_encryption: Optional[str] = None, params_source: Optional[dict] = None, ): """ @@ -194,6 +197,8 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): bool(params.get("s3_use_virtual_hosted_style", False)) or s3_use_virtual_hosted_style ) + self.s3_server_side_encryption = params.get("s3_server_side_encryption") or s3_server_side_encryption + return async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): @@ -273,6 +278,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): async def async_upload_data_to_s3(self, batch_logging_element: s3BatchLoggingElement): try: + import base64 import hashlib import requests @@ -317,14 +323,23 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): # Calculate SHA256 hash of the content content_hash = hashlib.sha256(json_string.encode("utf-8")).hexdigest() + content_md5 = base64.b64encode( + hashlib.md5(json_string.encode("utf-8"), usedforsecurity=False).digest() + ).decode() # Prepare the request headers = { "Content-Type": "application/json", + "Content-MD5": content_md5, "x-amz-content-sha256": content_hash, "Content-Language": "en", "Content-Disposition": f'inline; filename="{batch_logging_element.s3_object_download_filename}"', "Cache-Control": "private, immutable, max-age=31536000, s-maxage=0", + **( + {"x-amz-server-side-encryption": self.s3_server_side_encryption} + if self.s3_server_side_encryption + else {} + ), } req = requests.Request("PUT", url, data=json_string, headers=headers) prepped = req.prepare() @@ -447,6 +462,7 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): def upload_data_to_s3(self, batch_logging_element: s3BatchLoggingElement): try: + import base64 import hashlib import requests @@ -482,14 +498,23 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): # Calculate SHA256 hash of the content content_hash = hashlib.sha256(json_string.encode("utf-8")).hexdigest() + content_md5 = base64.b64encode( + hashlib.md5(json_string.encode("utf-8"), usedforsecurity=False).digest() + ).decode() # Prepare the request headers = { "Content-Type": "application/json", + "Content-MD5": content_md5, "x-amz-content-sha256": content_hash, "Content-Language": "en", "Content-Disposition": f'inline; filename="{batch_logging_element.s3_object_download_filename}"', "Cache-Control": "private, immutable, max-age=31536000, s-maxage=0", + **( + {"x-amz-server-side-encryption": self.s3_server_side_encryption} + if self.s3_server_side_encryption + else {} + ), } req = requests.Request("PUT", url, data=json_string, headers=headers) prepped = req.prepare() diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 60100e8c2fd..21d990e8e60 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -19,9 +19,11 @@ from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.websearch_interception.tools import ( get_litellm_web_search_tool, get_litellm_web_search_tool_openai, + get_litellm_web_search_tool_responses, is_anthropic_native_web_search_tool, is_web_search_tool, is_web_search_tool_chat_completion, + is_web_search_tool_responses, ) from litellm.integrations.websearch_interception.transformation import ( WebSearchTransformation, @@ -32,11 +34,12 @@ from litellm.types.integrations.websearch_interception import ( ) from litellm.types.integrations.custom_logger import ( CHAT_COMPLETION_AGENTIC_SURFACE, + RESPONSES_AGENTIC_SURFACE, AgenticLoopPlan, AgenticLoopRequestPatch, ) from litellm.types.llms.openai import AllMessageValues -from litellm.types.utils import LlmProviders +from litellm.types.utils import CallTypes, LlmProviders from litellm.utils import ProviderConfigManager # Key used to flag, on per-request kwargs, that the originating client sent @@ -91,6 +94,7 @@ class WebSearchInterceptionLogger(CustomLogger): messages: List[Dict], tools: Optional[List[Dict]], custom_llm_provider: Optional[str], + kwargs: Optional[dict[str, Any]] = None, ) -> Optional[Dict[str, Any]]: """ Short-circuit web-search-only requests by executing the search directly. @@ -176,7 +180,10 @@ class WebSearchInterceptionLogger(CustomLogger): # Execute search — keep the structured SearchResponse so the native # block can carry per-result url/title/page_age. try: - search_result_text, structured = await self._execute_search(query) + if kwargs is None: + search_result_text, structured = await self._execute_search(query) + else: + search_result_text, structured = await self._execute_search(query, kwargs=kwargs) except Exception as e: verbose_logger.error(f"WebSearchInterception: Short-circuit search failed: {e}") search_result_text, structured = f"Search failed: {e}", None @@ -247,6 +254,9 @@ class WebSearchInterceptionLogger(CustomLogger): if not tools: return None + if call_type in (CallTypes.responses, CallTypes.aresponses): + return self._convert_responses_tools(kwargs=kwargs, tools=tools) + # Check if any tool is a web search tool (native or already LiteLLM standard) has_websearch = any(is_web_search_tool(t) for t in tools) @@ -287,6 +297,26 @@ class WebSearchInterceptionLogger(CustomLogger): return kwargs + def _convert_responses_tools(self, kwargs: dict[str, Any], tools: list[dict[str, Any]]) -> dict | None: + """Convert Responses API web search tools to the LiteLLM standard function tool.""" + if not any(is_web_search_tool_responses(tool) for tool in tools): + return None + + verbose_logger.debug("WebSearchInterception: Converting Responses web_search tools to LiteLLM standard") + + converted_tools = [ + get_litellm_web_search_tool_responses() if is_web_search_tool_responses(tool) else tool for tool in tools + ] + + converted_kwargs = {**kwargs, "tools": converted_tools} + + if kwargs.get("stream"): + verbose_logger.debug("WebSearchInterception: deployment hook converting stream=True to stream=False") + converted_kwargs["stream"] = False + converted_kwargs["_websearch_interception_converted_stream"] = True + + return converted_kwargs + @classmethod def from_config_yaml(cls, config: WebSearchInterceptionConfig) -> "WebSearchInterceptionLogger": """ @@ -457,6 +487,17 @@ class WebSearchInterceptionLogger(CustomLogger): kwargs=kwargs, ) + if kwargs.get("_agentic_loop_api_surface") == RESPONSES_AGENTIC_SURFACE: + return await self.async_should_run_responses_agentic_loop( + response=response, + model=model, + messages=messages, + tools=tools, + stream=stream, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + ) + verbose_logger.debug(f"WebSearchInterception: Hook called! provider={custom_llm_provider}, stream={stream}") verbose_logger.debug(f"WebSearchInterception: Response type: {type(response)}") @@ -593,6 +634,54 @@ class WebSearchInterceptionLogger(CustomLogger): } return True, tools_dict + async def async_should_run_responses_agentic_loop( + self, + response: Any, + model: str, + messages: list[dict], + tools: list[dict] | None, + stream: bool, + custom_llm_provider: str, + kwargs: dict, + ) -> tuple[bool, dict]: + """Check if WebSearch interception is needed for the Responses API.""" + verbose_logger.debug( + f"WebSearchInterception: Responses hook called! provider={custom_llm_provider}, stream={stream}" + ) + + if self.enabled_providers is not None and custom_llm_provider not in self.enabled_providers: + verbose_logger.debug( + f"WebSearchInterception: Skipping provider {custom_llm_provider} (not in enabled list: {self.enabled_providers})" + ) + return False, {} + + has_websearch_tool = any(is_web_search_tool_responses(t) for t in (tools or [])) + if not has_websearch_tool: + verbose_logger.debug("WebSearchInterception: No litellm_web_search tool in responses request") + return False, {} + + should_intercept, tool_calls = WebSearchTransformation.transform_request( + response=response, + stream=stream, + response_format="responses", + ) + + if not should_intercept: + verbose_logger.debug("WebSearchInterception: No WebSearch function_call detected in responses output") + return False, {} + + verbose_logger.debug( + f"WebSearchInterception: Detected {len(tool_calls)} WebSearch function_call(s), executing agentic loop" + ) + + tools_dict = { + "tool_calls": tool_calls, + "tool_type": "websearch", + "provider": custom_llm_provider, + "response_format": "responses", + } + return True, tools_dict + async def async_run_agentic_loop( self, tools: Dict, @@ -651,6 +740,18 @@ class WebSearchInterceptionLogger(CustomLogger): kwargs=kwargs, ) + if kwargs.get("_agentic_loop_api_surface") == RESPONSES_AGENTIC_SURFACE: + return await self.async_build_responses_agentic_loop_plan( + tools=tools, + model=model, + messages=messages, + response=response, + optional_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, + stream=stream, + kwargs=kwargs, + ) + tool_calls = tools["tool_calls"] thinking_blocks = tools.get("thinking_blocks", []) request_patch, structured_results = await self._build_anthropic_request_patch( @@ -805,6 +906,133 @@ class WebSearchInterceptionLogger(CustomLogger): metadata={"tool_type": "websearch", "response_format": response_format}, ) + async def async_build_responses_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"] + request_patch = await self._build_responses_request_patch( + model=model, + messages=messages, + tool_calls=tool_calls, + optional_params=optional_params, + kwargs=kwargs, + ) + return AgenticLoopPlan( + run_agentic_loop=True, + request_patch=request_patch, + metadata={"tool_type": "websearch", "response_format": "responses"}, + ) + + async def _build_responses_request_patch( + self, + model: str, + messages: Union[str, list[dict]], + tool_calls: list[dict], + optional_params: dict, + kwargs: dict, + ) -> AgenticLoopRequestPatch: + """Execute litellm.asearch() and build a Responses API rerun patch.""" + search_tasks = [ + ( + self._execute_search(tool_call["input"]["query"], kwargs=kwargs) + if isinstance(tool_call.get("input"), dict) and tool_call["input"].get("query") + else self._create_empty_search_result() + ) + for tool_call in tool_calls + ] + + verbose_logger.debug(f"WebSearchInterception: Executing {len(search_tasks)} responses search(es) in parallel") + search_results = await asyncio.gather(*search_tasks, return_exceptions=True) + + search_texts = [self._extract_search_text(result) for result in search_results] + + followup_items = [ + item + for tool_call, search_text in zip(tool_calls, search_texts) + for item in ( + { + "type": "function_call", + "call_id": tool_call.get("call_id"), + "name": LITELLM_WEB_SEARCH_TOOL_NAME, + "arguments": tool_call.get("arguments", ""), + }, + { + "type": "function_call_output", + "call_id": tool_call.get("call_id"), + "output": search_text, + }, + ) + ] + + input_list = self._normalize_responses_input(messages) + followup_items + + tools_param = optional_params.get("tools") + optional_params_clean = { + k: v + for k, v in optional_params.items() + if k not in {"tools", "tool_choice", "stream", "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 { + "_agentic_loop_api_surface", + "litellm_logging_obj", + "acompletion", + "custom_llm_provider", + "model_alias_map", + } + } + + full_model_name = model + if "/" not in model and isinstance(kwargs.get("custom_llm_provider"), str): + full_model_name = f"{kwargs['custom_llm_provider']}/{model}" + + verbose_logger.debug( + "WebSearchInterception: Built responses request patch model=%s input_items=%d searches=%d", + full_model_name, + len(input_list), + len(search_texts), + ) + + return AgenticLoopRequestPatch( + model=full_model_name, + messages=input_list, + tools=tools_param if isinstance(tools_param, list) else None, + optional_params=optional_params_clean, + kwargs=kwargs_for_followup, + ) + + @staticmethod + def _normalize_responses_input(messages: Union[str, list[dict]]) -> list[dict]: + if isinstance(messages, str): + return [{"role": "user", "content": messages}] + if isinstance(messages, list): + return list(messages) + return [] + + @staticmethod + def _extract_search_text(result: Any) -> str: + if isinstance(result, Exception): + verbose_logger.error(f"WebSearchInterception: Responses search failed with error: {str(result)}") + return f"Search failed: {str(result)}" + if isinstance(result, tuple) and len(result) == 2: + text_value, _ = result + return text_value if isinstance(text_value, str) else str(text_value) + verbose_logger.debug(f"WebSearchInterception: Unexpected search result type {type(result)}") + return str(result) + @staticmethod def _resolve_max_tokens( optional_params: Dict, @@ -936,7 +1164,7 @@ class WebSearchInterceptionLogger(CustomLogger): query = tool_call["input"].get("query") if query: verbose_logger.debug(f"WebSearchInterception: Queuing search for query='{query}'") - search_tasks.append(self._execute_search(query)) + search_tasks.append(self._execute_search(query, kwargs=kwargs)) else: verbose_logger.debug(f"WebSearchInterception: Tool call {tool_call['id']} has no query") # Add empty result for tools without query @@ -1009,7 +1237,9 @@ class WebSearchInterceptionLogger(CustomLogger): ) return patch, structured_results - async def _execute_search(self, query: str) -> Tuple[str, Optional[SearchResponse]]: + async def _execute_search( + self, query: str, kwargs: Optional[dict[str, Any]] = None + ) -> Tuple[str, Optional[SearchResponse]]: """ Execute a single web search using router's search tools. @@ -1031,36 +1261,13 @@ class WebSearchInterceptionLogger(CustomLogger): ) llm_router = None - # Determine search provider from router's search_tools + search_tool = self._select_search_tool_from_router(llm_router=llm_router) search_provider: Optional[str] = None - if llm_router is not None and hasattr(llm_router, "search_tools"): - if self.search_tool_name: - # Find specific search tool by name - matching_tools = [ - tool - for tool in llm_router.search_tools - if tool.get("search_tool_name") == self.search_tool_name - ] - if matching_tools: - search_tool = matching_tools[0] - search_provider = search_tool.get("litellm_params", {}).get("search_provider") - verbose_logger.debug( - f"WebSearchInterception: Found search tool '{self.search_tool_name}' " - f"with provider '{search_provider}'" - ) - else: - verbose_logger.debug( - f"WebSearchInterception: Search tool '{self.search_tool_name}' not found in router, " - "falling back to first available or perplexity" - ) - - # If no specific tool or not found, use first available - if not search_provider and llm_router.search_tools: - first_tool = llm_router.search_tools[0] - search_provider = first_tool.get("litellm_params", {}).get("search_provider") - verbose_logger.debug( - f"WebSearchInterception: Using first available search tool with provider '{search_provider}'" - ) + search_litellm_params: dict[str, Any] = {} + if search_tool is not None: + await self._authorize_search_tool(search_tool=search_tool, kwargs=kwargs) + search_litellm_params = dict(search_tool.get("litellm_params", {}) or {}) + search_provider = search_litellm_params.get("search_provider") # Fallback to perplexity if no router or no search tools configured if not search_provider: @@ -1073,7 +1280,12 @@ class WebSearchInterceptionLogger(CustomLogger): verbose_logger.debug( f"WebSearchInterception: Executing search for '{query}' using provider '{search_provider}'" ) - result = await litellm.asearch(query=query, search_provider=search_provider) + search_kwargs = { + key: value + for key, value in search_litellm_params.items() + if key != "search_provider" and value is not None + } + result = await litellm.asearch(query=query, search_provider=search_provider, **search_kwargs) # Format using transformation function search_result_text = WebSearchTransformation.format_search_response(result) @@ -1086,6 +1298,107 @@ class WebSearchInterceptionLogger(CustomLogger): verbose_logger.error(f"WebSearchInterception: Search failed for '{query}': {str(e)}") raise + async def _authorize_search_tool( + self, + search_tool: dict[str, Any], + kwargs: Optional[dict[str, Any]], + ) -> None: + search_tool_name = search_tool.get("search_tool_name") + if not isinstance(search_tool_name, str) or not search_tool_name: + return + + user_api_key_auth = self._get_user_api_key_auth_from_kwargs(kwargs) + if user_api_key_auth is None: + return + + from litellm.proxy.auth.auth_checks import ( + can_key_call_search_tool, + can_team_call_search_tool, + get_team_object, + ) + + await can_key_call_search_tool( + search_tool_name=search_tool_name, + valid_token=user_api_key_auth, + ) + + team_id = getattr(user_api_key_auth, "team_id", None) + if team_id: + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + team_object = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=getattr(user_api_key_auth, "parent_otel_span", None), + proxy_logging_obj=proxy_logging_obj, + ) + await can_team_call_search_tool( + search_tool_name=search_tool_name, + team_object=team_object, + ) + + @staticmethod + def _get_user_api_key_auth_from_kwargs(kwargs: Optional[dict[str, Any]]) -> Any: + if not kwargs: + return None + + for metadata_key in ("metadata", "litellm_metadata"): + metadata = kwargs.get(metadata_key) + if isinstance(metadata, dict) and metadata.get("user_api_key_auth") is not None: + return metadata["user_api_key_auth"] + + litellm_params = kwargs.get("litellm_params") + if not isinstance(litellm_params, dict): + return None + + for metadata_key in ("metadata", "litellm_metadata"): + metadata = litellm_params.get(metadata_key) + if isinstance(metadata, dict) and metadata.get("user_api_key_auth") is not None: + return metadata["user_api_key_auth"] + + return None + + def _select_search_tool_from_router(self, llm_router: Any) -> Optional[dict[str, Any]]: + if llm_router is None or not hasattr(llm_router, "search_tools"): + return None + search_tools = list(getattr(llm_router, "search_tools") or []) + return self._select_search_tool_from_list(search_tools=search_tools, source="router") + + def _select_search_tool_from_list( + self, + search_tools: list[dict[str, Any]], + source: str, + ) -> Optional[dict[str, Any]]: + if self.search_tool_name: + matching_tools = [tool for tool in search_tools if tool.get("search_tool_name") == self.search_tool_name] + if matching_tools: + search_provider = (matching_tools[0].get("litellm_params", {}) or {}).get("search_provider") + verbose_logger.debug( + f"WebSearchInterception: Found search tool '{self.search_tool_name}' " + f"from {source} with provider '{search_provider}'" + ) + return matching_tools[0] + verbose_logger.debug( + f"WebSearchInterception: Search tool '{self.search_tool_name}' not found in {source}, " + "falling back to first available or perplexity" + ) + + if search_tools: + first_tool = search_tools[0] + search_provider = (first_tool.get("litellm_params", {}) or {}).get("search_provider") + verbose_logger.debug( + f"WebSearchInterception: Using first available search tool from {source} " + f"with provider '{search_provider}'" + ) + return first_tool + + return None + async def _execute_chat_completion_agentic_loop( self, model: str, @@ -1145,7 +1458,7 @@ class WebSearchInterceptionLogger(CustomLogger): if query: verbose_logger.debug(f"WebSearchInterception: Queuing search for query='{query}'") - search_tasks.append(self._execute_search(query)) + search_tasks.append(self._execute_search(query, kwargs=kwargs)) else: verbose_logger.debug(f"WebSearchInterception: Tool call {tool_call.get('id')} has no query") # Add empty result for tools without query diff --git a/litellm/integrations/websearch_interception/tools.py b/litellm/integrations/websearch_interception/tools.py index b29372af9ed..14c8aea0908 100644 --- a/litellm/integrations/websearch_interception/tools.py +++ b/litellm/integrations/websearch_interception/tools.py @@ -82,6 +82,75 @@ def get_litellm_web_search_tool_openai() -> Dict[str, Any]: } +def get_litellm_web_search_tool_responses() -> dict[str, Any]: + """ + Get the standard LiteLLM web search tool definition in Responses API format. + + Used by async_pre_call_deployment_hook on the Responses API path, where a + function tool is a flat object (``type: "function"`` with a top-level + ``name`` and ``parameters``) rather than the nested ``function`` wrapper + used by Chat Completions. + + Returns: + Dict containing the Responses-style function tool definition. + """ + return { + "type": "function", + "name": LITELLM_WEB_SEARCH_TOOL_NAME, + "description": ( + "Search the web for information. Use this when you need current " + "information or answers to questions that require up-to-date data." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to execute", + } + }, + "required": ["query"], + }, + } + + +def is_web_search_tool_responses(tool: dict[str, Any]) -> bool: + """ + Check if a tool is a web search tool for the Responses API. + + Detects: + - OpenAI native Responses web search tools, whose ``type`` is one of + ``web_search``, ``web_search_2025_08_26``, ``web_search_preview``, + ``web_search_preview_2025_03_11`` (matched by the ``web_search`` prefix) + - The LiteLLM standard function tool in Responses shape: + ``{"type": "function", "name": "litellm_web_search"}`` + + Args: + tool: Tool dictionary to check + + Returns: + True if tool is a Responses-API web search tool + + Example: + >>> is_web_search_tool_responses({"type": "web_search"}) + True + >>> is_web_search_tool_responses({"type": "web_search_preview"}) + True + >>> is_web_search_tool_responses({"type": "function", "name": "litellm_web_search"}) + True + >>> is_web_search_tool_responses({"type": "function", "name": "get_weather"}) + False + """ + tool_type = tool.get("type", "") + if not isinstance(tool_type, str): + return False + + if tool_type == "function": + return tool.get("name") == LITELLM_WEB_SEARCH_TOOL_NAME + + return tool_type == "web_search" or tool_type.startswith("web_search_") + + def is_web_search_tool_chat_completion(tool: Dict[str, Any]) -> bool: """ Check if a tool is a web search tool for Chat Completions API (strict check). diff --git a/litellm/integrations/websearch_interception/transformation.py b/litellm/integrations/websearch_interception/transformation.py index 7bbcd7ebff6..282d75d3d4d 100644 --- a/litellm/integrations/websearch_interception/transformation.py +++ b/litellm/integrations/websearch_interception/transformation.py @@ -59,9 +59,73 @@ class WebSearchTransformation: # Parse non-streaming response based on format if response_format == "openai": return WebSearchTransformation._detect_from_openai_response(response) + elif response_format == "responses": + return WebSearchTransformation._detect_from_responses_response(response) else: return WebSearchTransformation._detect_from_non_streaming_response(response) + @staticmethod + def _detect_from_responses_response( + response: Any, + ) -> tuple[bool, list[dict]]: + """Parse a Responses API response for ``litellm_web_search`` function calls. + + After pre-request conversion the native web search tool is replaced by a + ``litellm_web_search`` function tool, so the model emits ``function_call`` + items in ``response.output`` instead of a native ``web_search_call``. + """ + if isinstance(response, dict): + output = response.get("output", []) + else: + output = getattr(response, "output", None) or [] + + if not isinstance(output, list): + return False, [] + + tool_calls: list[dict] = [] + for item in output: + if isinstance(item, dict): + item_type = item.get("type") + item_name = item.get("name") + call_id = item.get("call_id") + arguments = item.get("arguments", "") + else: + item_type = getattr(item, "type", None) + item_name = getattr(item, "name", None) + call_id = getattr(item, "call_id", None) + arguments = getattr(item, "arguments", "") + + if item_type != "function_call" or item_name != LITELLM_WEB_SEARCH_TOOL_NAME: + continue + + if isinstance(arguments, str): + try: + parsed_input = json.loads(arguments) if arguments else {} + except json.JSONDecodeError: + verbose_logger.warning( + f"WebSearchInterception: Failed to parse function_call arguments: {arguments}" + ) + parsed_input = {} + elif isinstance(arguments, dict): + parsed_input = arguments + else: + parsed_input = {} + + arguments_str = arguments if isinstance(arguments, str) else json.dumps(parsed_input) + tool_calls.append( + { + "id": call_id, + "call_id": call_id, + "type": "function_call", + "name": item_name, + "arguments": arguments_str, + "input": parsed_input, + } + ) + verbose_logger.debug(f"WebSearchInterception: Found {item_name} function_call with call_id={call_id}") + + return len(tool_calls) > 0, tool_calls + @staticmethod def _detect_from_non_streaming_response( response: Any, diff --git a/litellm/interactions/streaming_iterator.py b/litellm/interactions/streaming_iterator.py index 45c5443cfd2..0d9d1b4579c 100644 --- a/litellm/interactions/streaming_iterator.py +++ b/litellm/interactions/streaming_iterator.py @@ -174,22 +174,15 @@ class InteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator): logging_response = copy.deepcopy(self.completed_response) asyncio.create_task( - self.logging_obj.async_success_handler( - result=logging_response, + self.logging_obj.dispatch_success_handlers( + logging_response, start_time=self.start_time, end_time=datetime.now(), cache_hit=None, + prefer_async_handlers=True, ) ) - executor.submit( - self.logging_obj.success_handler, - result=logging_response, - cache_hit=None, - start_time=self.start_time, - end_time=datetime.now(), - ) - class SyncInteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator): """ diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index f86243c73b7..e5007ceec34 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -123,6 +123,34 @@ def process_audio_file(audio_file: FileTypes) -> ProcessedAudioFile: return ProcessedAudioFile(file_content=file_content, filename=filename, content_type=content_type) +BARE_ISO_639_1_TO_BCP47 = { + "en": "en-US", + "es": "es-ES", + "de": "de-DE", + "fr": "fr-FR", + "it": "it-IT", + "pt": "pt-BR", + "ja": "ja-JP", + "ko": "ko-KR", + "zh": "zh-CN", + "ru": "ru-RU", + "hi": "hi-IN", + "ar": "ar-SA", +} + + +def normalize_transcription_language_to_bcp47(language: str) -> str: + """ + OpenAI's transcription `language` param accepts bare ISO-639-1 codes like + ``en``; speech APIs such as Google Speech-to-Text and NVIDIA Riva require + BCP-47 like ``en-US``. Map the most common bare codes and pass through + anything already region-qualified (or unknown, for a clear provider error). + """ + if "-" in language: + return language + return BARE_ISO_639_1_TO_BCP47.get(language.lower(), language) + + def get_audio_file_name(file_obj: FileTypes) -> str: """ Safely get the name of a file-like object or return its string representation. diff --git a/litellm/litellm_core_utils/cli_token_utils.py b/litellm/litellm_core_utils/cli_token_utils.py index eb01359cdc0..e730f60bc3b 100644 --- a/litellm/litellm_core_utils/cli_token_utils.py +++ b/litellm/litellm_core_utils/cli_token_utils.py @@ -7,6 +7,7 @@ This module has no dependencies on proxy code and can be safely imported at the import json import os +import time from pathlib import Path from typing import Optional @@ -68,3 +69,17 @@ def get_litellm_gateway_api_key( if stored_url != expected_base_url.rstrip("/"): return None return token_data["key"] + + +def is_cli_token_fresh(token_data: dict, buffer_hours: float = 0.1) -> bool: + """Check whether a cached CLI token (as stored in token.json) is still + within its expiration window. Used by `lite auth print-token` to fail + fast, without a network round trip, once the cached token is past + `LITELLM_CLI_JWT_EXPIRATION_HOURS`.""" + from litellm.constants import CLI_JWT_EXPIRATION_HOURS + + timestamp = token_data.get("timestamp") + if not isinstance(timestamp, (int, float)): + return False + age_hours = (time.time() - timestamp) / 3600 + return age_hours < (CLI_JWT_EXPIRATION_HOURS - buffer_hours) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 2441cbb3903..fdab3d5b9d4 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -95,6 +95,9 @@ class ExceptionCheckers: if "current length is" in _error_str_lowercase and "while limit is" in _error_str_lowercase: return True + if "maximum input length is" in _error_str_lowercase and "tokens" in _error_str_lowercase: + return True + return False @staticmethod @@ -1944,7 +1947,7 @@ def _map_azure_exception( response=getattr(original_exception, "response", None), body=getattr(original_exception, "body", None), ) - elif "invalid_request_error" in error_str: + elif "invalid_request_error" in error_str and getattr(original_exception, "status_code", None) in (None, 400): raise BadRequestError( message=f"AzureException BadRequestError - {message}", llm_provider="azure", @@ -1986,6 +1989,14 @@ def _map_azure_exception( litellm_debug_info=extra_information, response=getattr(original_exception, "response", None), ) + elif original_exception.status_code == 404: + raise NotFoundError( + message=f"AzureException NotFoundError - {message}", + llm_provider="azure", + model=model, + litellm_debug_info=extra_information, + response=getattr(original_exception, "response", None), + ) elif original_exception.status_code == 408: raise Timeout( message=f"AzureException Timeout - {message}", @@ -2173,7 +2184,7 @@ def exception_type( # type: ignore litellm_response_headers = _get_response_headers(original_exception=original_exception) try: error_str = redact_string(str(original_exception)) if _ENABLE_SECRET_REDACTION else str(original_exception) - if model: + if model or custom_llm_provider: if hasattr(original_exception, "message"): error_str = ( redact_string(str(original_exception.message)) diff --git a/litellm/litellm_core_utils/fallback_generalizations.py b/litellm/litellm_core_utils/fallback_generalizations.py index abc171f900a..410bb9623fe 100644 --- a/litellm/litellm_core_utils/fallback_generalizations.py +++ b/litellm/litellm_core_utils/fallback_generalizations.py @@ -3,52 +3,69 @@ Declarative fallback generalizations for unknown / newly-released models. The ``fallback_generalizations`` block in ``model_prices_and_context_window.json`` holds an ordered list of rules. Each rule pairs a single case-insensitive regex -with the metadata to apply when a model name has no exact entry in the cost map. -The metadata is a partial cost-map entry: ``litellm_provider`` drives provider -routing, and the remaining fields (``mode``, ``supports_*``, context window, -pricing, ...) drive ``get_model_info`` / ``supports_*``. +with a ``model_info`` dict, and the structure of ``model_info`` decides which of +two kinds the rule is. -Precedence: rules are evaluated in file order and the first match wins. They are -consulted only after exact and case-insensitive lookups miss, so an exact entry -always takes precedence over a rule. +A ROUTING rule carries exactly one ``model_info`` key, ``litellm_provider``. It is +consumed only by ``get_llm_provider`` bare-id inference: the first routing rule +whose regex matches decides the provider. Routing rules never contribute to model +info. + +A CAPABILITY rule carries any ``model_info`` keys except ``litellm_provider`` +(``mode``, ``supports_*``, context window, pricing, ...). It is consumed by +``get_model_info`` fallback resolution: the ``model_info`` of ALL capability rules +whose regex matches is unioned in file order, with later rules overriding earlier +ones on key conflicts, and the caller backfills ``litellm_provider`` with the +provider it requested. If no capability rule matches, model-info resolution misses +as if no rules existed. + +LEGACY-SCHEMA SHIM (temporary, until the new-schema JSON reaches main): released +proxies fetch this JSON remotely from main, whose block still ships the old schema +where a rule mixes ``litellm_provider`` with capability keys and may inherit a +parent's ``model_info`` via ``extends``. Such a legacy rule is tolerated rather +than skipped: ``extends`` is resolved once at install time (single level, against +raw parents), and the resolved rule acts as BOTH kinds, a routing rule (its +``litellm_provider`` participates in first-hit inference) and a capability rule +(its full ``model_info``, provider included, participates in the union). New-schema +rules never mix the two and never use ``extends``. A rule whose +``litellm_provider`` is not a string is invalid and is warned about and skipped +(a warning rather than a crash, for the same remote-fetch reason). + +Rules are only consulted after exact and case-insensitive lookups miss, so an +exact cost-map entry always takes precedence over any rule. Patterns are matched case-insensitively with ``re.search`` and are not implicitly -anchored: a rule must include ``^`` and ``$`` (as the shipped rules do) to bind to -the whole model name, otherwise it matches as a substring. Keeping anchoring in the -regex makes the rule the single, self-contained source of truth for what it matches. - -A rule may set ``extends`` to the ``name`` of another rule to inherit that rule's -``model_info``; the rule's own ``model_info`` overrides the inherited keys, so a -narrow rule (for example a version-gated capability flag) carries only its delta -instead of duplicating the parent's pricing block. Inheritance is resolved once, -at install time, against each rule's raw (unresolved) ``model_info``; it is a -single level (a parent that itself extends is not chained). +anchored: a rule must include ``^`` and ``$`` to bind to the whole model name, +otherwise it matches as a substring. Keeping anchoring in the regex makes the rule +the single, self-contained source of truth for what it matches. Any other keys on a rule (for example a free-text ``description`` documenting what the regex matches) are ignored by the engine and exist only for the reader. -The compiled-regex list is built once and cached. ``match_fallback_generalization`` -is O(number of rules); callers must only invoke it on a cache miss. +Rules are compiled and classified once, at install time. The match functions are +O(number of rules); callers must only invoke them on a cache miss. """ import re -from typing import Optional +from dataclasses import dataclass +from typing import Optional, Union from litellm._logging import verbose_logger NAME_FIELD = "name" PATTERN_FIELD = "pattern" MODEL_INFO_FIELD = "model_info" -EXTENDS_FIELD = "extends" +PROVIDER_KEY = "litellm_provider" +LEGACY_EXTENDS_FIELD = "extends" -def _resolve_extends(rules: list) -> list: - """Expand ``extends`` inheritance so each rule's ``model_info`` is self-contained. +def _resolve_legacy_extends(rules: list) -> list: + """Expand legacy ``extends`` inheritance so each rule's ``model_info`` is self-contained. - A rule with ``extends: `` is rewritten with ``model_info`` set to the parent's - ``model_info`` overlaid by its own. Resolution is single-level and uses each rule's - raw ``model_info`` as the parent source. Non-dict rules and dangling parents are - passed through unchanged. + Compatibility shim for the old remote schema: single level, resolved against each + parent's raw ``model_info``, with the child's own keys winning on conflict. Non-dict + rules and dangling parents pass through unchanged; new-schema rules carry no + ``extends`` and are untouched. """ base_by_name = { rule[NAME_FIELD]: rule[MODEL_INFO_FIELD] @@ -58,84 +75,138 @@ def _resolve_extends(rules: list) -> list: and isinstance(rule.get(MODEL_INFO_FIELD), dict) } - def resolved(rule: dict) -> dict: - parent_name = rule.get(EXTENDS_FIELD) + def resolved(rule: object) -> object: + if not isinstance(rule, dict): + return rule + parent_name = rule.get(LEGACY_EXTENDS_FIELD) own_info = rule.get(MODEL_INFO_FIELD) parent_info = base_by_name.get(parent_name) if isinstance(parent_name, str) else None if parent_info is None or not isinstance(own_info, dict): return rule return {**rule, MODEL_INFO_FIELD: {**parent_info, **own_info}} - return [resolved(rule) if isinstance(rule, dict) else rule for rule in rules] + return [resolved(rule) for rule in rules] + + +@dataclass(frozen=True, slots=True) +class _RoutingRule: + pattern: re.Pattern + provider: str + + +@dataclass(frozen=True, slots=True) +class _CapabilityRule: + pattern: re.Pattern + model_info: dict + + +_CompiledRule = Union[_RoutingRule, _CapabilityRule] + + +def _compile_rule(rule: object) -> tuple[_CompiledRule, ...]: + if not isinstance(rule, dict): + return () + pattern = rule.get(PATTERN_FIELD) + model_info = rule.get(MODEL_INFO_FIELD) + if not isinstance(pattern, str) or not isinstance(model_info, dict): + verbose_logger.warning( + "LiteLLM: skipping malformed fallback generalization rule %s (needs string '%s' and dict '%s').", + rule.get(NAME_FIELD, pattern), + PATTERN_FIELD, + MODEL_INFO_FIELD, + ) + return () + try: + compiled = re.compile(pattern, re.IGNORECASE) + except re.error as e: + verbose_logger.warning( + "LiteLLM: skipping fallback generalization rule with invalid regex %r: %s", + pattern, + e, + ) + return () + if PROVIDER_KEY not in model_info: + return (_CapabilityRule(pattern=compiled, model_info=model_info),) + provider = model_info[PROVIDER_KEY] + if not isinstance(provider, str): + verbose_logger.warning( + "LiteLLM: skipping invalid fallback generalization rule %s: '%s' in '%s' must be a string.", + rule.get(NAME_FIELD, pattern), + PROVIDER_KEY, + MODEL_INFO_FIELD, + ) + return () + if len(model_info) == 1: + return (_RoutingRule(pattern=compiled, provider=provider),) + return ( + _RoutingRule(pattern=compiled, provider=provider), + _CapabilityRule(pattern=compiled, model_info=model_info), + ) class _FallbackGeneralizations: - """Holds the active rule list and its lazily-compiled regex cache.""" + """Holds the raw rule list and its install-time-compiled routing and capability rules.""" def __init__(self) -> None: - self.rules: list[dict] = [] - self._compiled: Optional[list[tuple[re.Pattern, dict]]] = None + self.rules: list = [] + self.routing_rules: tuple = () + self.capability_rules: tuple = () - def set_rules(self, rules: Optional[list[dict]]) -> None: - self.rules = rules if isinstance(rules, list) else [] - self._compiled = None + def set_rules(self, rules: Optional[list]) -> None: + installed = rules if isinstance(rules, list) else [] + compiled = tuple(kind for rule in _resolve_legacy_extends(installed) for kind in _compile_rule(rule)) + self.rules = installed + self.routing_rules = tuple(rule for rule in compiled if isinstance(rule, _RoutingRule)) + self.capability_rules = tuple(rule for rule in compiled if isinstance(rule, _CapabilityRule)) - def _compile(self) -> list[tuple[re.Pattern, dict]]: - compiled: list[tuple[re.Pattern, dict]] = [] - for rule in self.rules: - if not isinstance(rule, dict): - continue - pattern = rule.get(PATTERN_FIELD) - model_info = rule.get(MODEL_INFO_FIELD) - if not isinstance(pattern, str) or not isinstance(model_info, dict): - verbose_logger.warning( - "LiteLLM: skipping malformed fallback generalization rule %s (needs string '%s' and dict '%s').", - rule.get("name", pattern), - PATTERN_FIELD, - MODEL_INFO_FIELD, - ) - continue - try: - compiled.append((re.compile(pattern, re.IGNORECASE), model_info)) - except re.error as e: - verbose_logger.warning( - "LiteLLM: skipping fallback generalization rule with invalid regex %r: %s", - pattern, - e, - ) - return compiled - - def match(self, model: str) -> Optional[dict]: + def match_routing(self, model: str) -> Optional[str]: if not model: return None - if self._compiled is None: - self._compiled = self._compile() - for pattern, model_info in self._compiled: - if pattern.search(model) is not None: - return dict(model_info) - return None + return next( + (rule.provider for rule in self.routing_rules if rule.pattern.search(model) is not None), + None, + ) + + def match_capabilities(self, model: str) -> Optional[dict]: + if not model: + return None + matched = tuple(rule.model_info for rule in self.capability_rules if rule.pattern.search(model) is not None) + if not matched: + return None + return {key: value for model_info in matched for key, value in model_info.items()} _registry = _FallbackGeneralizations() -def set_fallback_generalizations(rules: Optional[list[dict]]) -> None: - """Install the active rule list and invalidate the compiled-regex cache. +def set_fallback_generalizations(rules: Optional[list]) -> None: + """Install the active rule list, compiling and classifying each rule. - ``extends`` inheritance is resolved here, once, before the rules are stored. - Called once when the model cost map is loaded (and again on any reload). + Legacy ``extends`` inheritance is resolved here, once, before classification; + a legacy rule mixing ``litellm_provider`` with capability keys installs as both + kinds. Malformed and invalid-regex rules are warned about and skipped. Called + once when the model cost map is loaded (and again on any reload). """ - _registry.set_rules(_resolve_extends(rules) if isinstance(rules, list) else rules) + _registry.set_rules(rules) -def get_fallback_generalization_rules() -> list[dict]: +def get_fallback_generalization_rules() -> list: """Return the raw rule list (read-only view for callers/tests).""" return _registry.rules -def match_fallback_generalization(model: str) -> Optional[dict]: - """Return the ``model_info`` of the first rule whose regex matches ``model``. +def match_routing_generalization(model: str) -> Optional[str]: + """Return the provider of the first routing rule whose regex matches ``model``. O(number of rules). Only call this once exact lookups have missed. """ - return _registry.match(model) + return _registry.match_routing(model) + + +def match_capability_generalizations(model: str) -> Optional[dict]: + """Return the union of the ``model_info`` of every capability rule matching ``model``. + + Later rules override earlier ones on key conflicts. Returns ``None`` when no + capability rule matches. O(number of rules); only call once exact lookups have missed. + """ + return _registry.match_capabilities(model) diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index fbed9594a0b..b8ef9d8cca7 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -2,26 +2,8 @@ from typing import Optional from litellm.llms.openai.data_residency import infer_openai_data_residency -# Pre-define optional kwargs keys as frozenset for O(1) lookups -# These are extracted from kwargs only if present, avoiding unnecessary .get() calls -OPTIONAL_KWARGS_KEYS = frozenset( +AWS_CREDENTIAL_KWARGS_KEYS = frozenset( { - "azure_ad_token", - "tenant_id", - "client_id", - "client_secret", - "azure_username", - "azure_password", - "azure_scope", - "timeout", - "gcs_bucket_name", - "bucket_name", - "vertex_credentials", - "vertex_project", - "vertex_location", - "vertex_ai_project", - "vertex_ai_location", - "vertex_ai_credentials", "aws_region_name", "aws_access_key_id", "aws_secret_access_key", @@ -34,12 +16,40 @@ OPTIONAL_KWARGS_KEYS = frozenset( "aws_external_id", "aws_bedrock_runtime_endpoint", "aws_bedrock_project_id", - "tpm", - "rpm", - "use_xai_oauth", } ) +# Pre-define optional kwargs keys as frozenset for O(1) lookups +# These are extracted from kwargs only if present, avoiding unnecessary .get() calls +OPTIONAL_KWARGS_KEYS = ( + frozenset( + { + "azure_ad_token", + "tenant_id", + "client_id", + "client_secret", + "azure_username", + "azure_password", + "azure_scope", + "timeout", + "gcs_bucket_name", + "bucket_name", + "vertex_credentials", + "vertex_project", + "vertex_location", + "vertex_ai_project", + "vertex_ai_location", + "vertex_ai_credentials", + "tpm", + "rpm", + "itpm", + "otpm", + "use_xai_oauth", + } + ) + | AWS_CREDENTIAL_KWARGS_KEYS +) + # Backward-compatible alias for existing imports/tests. _OPTIONAL_KWARGS_KEYS = OPTIONAL_KWARGS_KEYS @@ -74,6 +84,7 @@ def get_litellm_params( proxy_server_request=None, acompletion=None, aembedding=None, + allm_passthrough_route=None, preset_cache_key=None, no_log=None, input_cost_per_second=None, @@ -116,6 +127,7 @@ def get_litellm_params( # Build base dict with explicit parameters (always included) litellm_params = { "acompletion": acompletion, + "allm_passthrough_route": allm_passthrough_route, "api_key": api_key, "force_timeout": force_timeout, "logger_fn": logger_fn, diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index a7a576ff167..487a7b7e25f 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -4,7 +4,7 @@ from urllib.parse import urlparse import litellm from litellm.constants import REPLICATE_MODEL_NAME_WITH_ID_LENGTH from litellm.litellm_core_utils.fallback_generalizations import ( - match_fallback_generalization, + match_routing_generalization, ) from litellm.llms.openai_like.json_loader import JSONProviderRegistry from litellm.secret_managers.main import get_secret, get_secret_str @@ -346,6 +346,9 @@ def get_llm_provider( elif endpoint == "https://pinstripes.io/v1": custom_llm_provider = "pinstripes" dynamic_api_key = get_secret_str("PINSTRIPES_API_KEY") + elif endpoint == "https://api.meta.ai/v1": + custom_llm_provider = "meta" + dynamic_api_key = get_secret_str("META_API_KEY") if api_base is not None and not isinstance(api_base, str): raise Exception("api base needs to be a string. api_base={}".format(api_base)) @@ -471,12 +474,10 @@ def get_llm_provider( custom_llm_provider = "sap" # Last resort for an otherwise-unknown model: a declarative - # fallback-generalization rule (e.g. routes future claude-* to anthropic). + # fallback-generalization routing rule (e.g. routes future claude-* to anthropic). # Exact provider matches above always win; this only runs on a miss. if not custom_llm_provider: - generalization = match_fallback_generalization(model) - if generalization is not None: - custom_llm_provider = generalization.get("litellm_provider") or None + custom_llm_provider = match_routing_generalization(model) if not custom_llm_provider: if litellm.suppress_debug_info is False: @@ -652,6 +653,10 @@ def _get_openai_compatible_provider_info( api_base = api_base or get_secret("DEEPSEEK_API_BASE") or "https://api.deepseek.com/beta" # type: ignore dynamic_api_key = api_key or get_secret_str("DEEPSEEK_API_KEY") + elif custom_llm_provider == "tencent": + api_base = api_base or get_secret("TENCENT_API_BASE") or "https://tokenhub-intl.tencentcloudmaas.com/v1" + + dynamic_api_key = api_key or get_secret_str("TENCENT_API_KEY") elif custom_llm_provider == "fireworks_ai": # fireworks is openai compatible, we just need to set this to custom_openai and have the api_base be https://api.fireworks.ai/inference/v1 ( diff --git a/litellm/litellm_core_utils/get_supported_openai_params.py b/litellm/litellm_core_utils/get_supported_openai_params.py index c4ddb4b7ee0..19149da0316 100644 --- a/litellm/litellm_core_utils/get_supported_openai_params.py +++ b/litellm/litellm_core_utils/get_supported_openai_params.py @@ -106,6 +106,8 @@ def get_supported_openai_params( return litellm.VLLMConfig().get_supported_openai_params(model=model) elif custom_llm_provider == "deepseek": return litellm.DeepSeekChatConfig().get_supported_openai_params(model=model) + elif custom_llm_provider == "tencent": + return litellm.TencentChatConfig().get_supported_openai_params(model=model) elif custom_llm_provider == "cohere_chat" or custom_llm_provider == "cohere": return litellm.CohereChatConfig().get_supported_openai_params(model=model) elif custom_llm_provider == "maritalk": diff --git a/litellm/litellm_core_utils/health_check_helpers.py b/litellm/litellm_core_utils/health_check_helpers.py index 405366382a1..9fc036e2a99 100644 --- a/litellm/litellm_core_utils/health_check_helpers.py +++ b/litellm/litellm_core_utils/health_check_helpers.py @@ -95,6 +95,17 @@ class HealthCheckHelpers: """ import litellm + logging_obj = filtered_model_params.get("litellm_logging_obj") + if logging_obj is not None: + api_base = filtered_model_params.get("api_base") + logging_obj.update_from_kwargs( + kwargs=filtered_model_params, + model=filtered_model_params.get("model"), + user=None, + optional_params={}, + litellm_params={"api_base": api_base} if api_base else None, + ) + if custom_llm_provider in LIST_BATCHES_SUPPORTED_PROVIDERS: return await litellm.alist_batches(**filtered_model_params) else: @@ -188,6 +199,7 @@ class HealthCheckHelpers: api_base=model_params.get("api_base", None), api_key=model_params.get("api_key", None), api_version=model_params.get("api_version", None), + model_params=model_params, ), "batch": lambda: HealthCheckHelpers._batch_health_check( custom_llm_provider=custom_llm_provider, diff --git a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py index ff5efe7fe7d..6606694e3a8 100644 --- a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py +++ b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py @@ -1,7 +1,23 @@ -from typing import Dict, Optional +from typing import Any, Dict, Iterator, Optional from litellm.types.utils import StandardCallbackDynamicParams +_CLIENT_CALLBACK_METADATA_SLOTS: tuple[str, ...] = ("litellm_metadata", "metadata") + + +def iter_client_callback_metadata_dicts( + kwargs: dict[str, Any], +) -> Iterator[tuple[str, dict[str, Any]]]: + litellm_params = kwargs.get("litellm_params") + if isinstance(litellm_params, dict): + nested = litellm_params.get("metadata") + if isinstance(nested, dict): + yield "litellm_params.metadata", nested + for key in _CLIENT_CALLBACK_METADATA_SLOTS: + candidate = kwargs.get(key) + if isinstance(candidate, dict): + yield key, candidate + def _is_env_reference(value: object) -> bool: return isinstance(value, str) and "os.environ/" in value @@ -55,6 +71,7 @@ _supported_callback_params = [ "dd_site", "dd_agent_host", "dd_agent_port", + "turn_off_message_logging", ] _request_blocked_callback_params = { @@ -87,18 +104,12 @@ def initialize_standard_callback_dynamic_params( validate_no_callback_env_reference(param, _param_value, source="request body") standard_callback_dynamic_params[param] = _param_value # type: ignore - # 2. Fallback: check "metadata" or "litellm_params" -> "metadata" - metadata = (kwargs.get("metadata") or {}).copy() - litellm_params = kwargs.get("litellm_params") or {} - if isinstance(litellm_params, dict): - metadata.update(litellm_params.get("metadata") or {}) - - if isinstance(metadata, dict): + for slot_label, metadata in iter_client_callback_metadata_dicts(kwargs): for param in _supported_callback_params: if param in _request_blocked_callback_params: continue if param not in standard_callback_dynamic_params and param in metadata: _param_value = metadata.get(param) - validate_no_callback_env_reference(param, _param_value, source="metadata") + validate_no_callback_env_reference(param, _param_value, source=slot_label) standard_callback_dynamic_params[param] = _param_value # type: ignore return standard_callback_dynamic_params diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index fcf12c1c608..6f62663f141 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -73,6 +73,7 @@ from litellm.litellm_core_utils.model_param_helper import ModelParamHelper from litellm.litellm_core_utils.redact_messages import ( redact_message_input_output_from_custom_logger, redact_message_input_output_from_logging, + redact_streaming_responses_for_custom_logger, ) from litellm.llms.base_llm.ocr.transformation import OCRResponse from litellm.llms.base_llm.search.transformation import SearchResponse @@ -1530,6 +1531,7 @@ class Logging(LiteLLMLoggingBaseClass): and litellm_params.get(CallTypes.aembedding.value, False) is not True and litellm_params.get(CallTypes.aimage_generation.value, False) is not True and litellm_params.get(CallTypes.atranscription.value, False) is not True + and litellm_params.get(CallTypes.allm_passthrough_route.value, False) is not True ) def _is_assembled_stream_success(self, result=None) -> bool: @@ -2575,6 +2577,9 @@ class Logging(LiteLLMLoggingBaseClass): model_call_details = callback.redact_standard_logging_payload_from_model_call_details( model_call_details=model_call_details ) + model_call_details = redact_streaming_responses_for_custom_logger( + model_call_details=model_call_details, custom_logger=callback + ) ################################## if self.stream is True: if "async_complete_streaming_response" in model_call_details: @@ -5246,10 +5251,15 @@ def get_standard_logging_object_payload( call_type = kwargs.get("call_type") cache_hit = kwargs.get("cache_hit", False) # Extract usage as a plain dict, avoiding Pydantic round-trip - usage_dict = StandardLoggingPayloadSetup.get_usage_as_dict( + raw_usage_dict = StandardLoggingPayloadSetup.get_usage_as_dict( response_obj=response_obj, combined_usage_object=cast(Optional[Usage], kwargs.get("combined_usage_object")), ) + usage_dict = ( + {**raw_usage_dict, "output_image_count": len(init_response_obj.data)} + if isinstance(init_response_obj, ImageResponse) and init_response_obj.data + else raw_usage_dict + ) id = response_obj.get("id", kwargs.get("litellm_call_id")) diff --git a/litellm/litellm_core_utils/llm_cost_calc/tiered_pricing.py b/litellm/litellm_core_utils/llm_cost_calc/tiered_pricing.py new file mode 100644 index 00000000000..836b02f2049 --- /dev/null +++ b/litellm/litellm_core_utils/llm_cost_calc/tiered_pricing.py @@ -0,0 +1,139 @@ +""" +Provider-neutral graduated tiered pricing calculation. + +Shared by provider cost calculators (e.g. Dashscope) and the proxy budget +reservation logic so neither has to depend on the other. +""" + +from typing import List, Optional, Union + + +def _coerce_cost_per_token(value: Union[float, int, str, None]) -> float: + """ + Coerce a per-token cost into a float. + + Model cost values loaded from YAML config may arrive as strings (e.g. + scientific notation like "4e-07"), which would break arithmetic. + """ + if value is None: + return 0.0 + if isinstance(value, str): + try: + return float(value) + except ValueError: + return 0.0 + return float(value) + + +def calculate_tiered_cost( + tokens: int, + tiered_pricing: List[dict], + cost_key: str, + fallback_cost_key: Optional[str] = None, +) -> float: + """ + Calculate cost for a given number of tokens based on a true tiered pricing structure. + + This function iterates through sorted pricing tiers, calculates the cost for the + number of tokens that fall into each tier's range, and sums them up to get the total cost. + + Args: + tokens (int): The total number of tokens to calculate the cost for. + tiered_pricing (List[dict]): A list of dictionaries, where each dictionary + represents a pricing tier. + cost_key (str): The key in the tier dictionary that holds the per-token cost + (e.g., 'input_cost_per_token'). + fallback_cost_key (Optional[str], optional): A fallback key to use if the + primary `cost_key` is not found in a tier. Defaults to None. + + Returns: + float: The total calculated cost for the given tokens. + + Example: + >>> tiered_pricing = [ + ... {"range": [0, 100000], "input_cost_per_token": 0.0001}, + ... {"range": [100000, 500000], "input_cost_per_token": 0.00005}, + ... ] + + Calculating cost for 150,000 tokens: + (100,000 * 0.0001) + (50,000 * 0.00005) = $12.5 + """ + if not tiered_pricing or tokens <= 0: + return 0.0 + + total_cost = 0.0 + tokens_processed = 0 + + sorted_tiers = sorted(tiered_pricing, key=lambda x: x.get("range", [0, 0])[0]) + + for tier in sorted_tiers: + if tokens_processed >= tokens: + break + + tier_range = tier.get("range", []) + if len(tier_range) != 2: + continue + + range_start, range_end = tier_range + + if tokens <= range_start: + continue + + tier_start = max(range_start, tokens_processed) + tier_end = min(range_end, tokens) + + if tier_end > tier_start: + tokens_in_tier = tier_end - tier_start + cost_per_token = tier.get(cost_key) or tier.get(fallback_cost_key, 0) + total_cost += tokens_in_tier * _coerce_cost_per_token(cost_per_token) + tokens_processed = tier_end + + # After loop, check if any tokens remain (i.e., tokens > highest tier's end range) + # and charge them at the last tier's rate. + if tokens_processed < tokens and sorted_tiers: + last_tier = sorted_tiers[-1] + remaining_tokens = tokens - tokens_processed + cost_per_token = last_tier.get(cost_key) or last_tier.get(fallback_cost_key, 0) + total_cost += remaining_tokens * _coerce_cost_per_token(cost_per_token) + + return total_cost + + +def select_tier_for_input( + tiered_pricing: List[dict], + input_tokens: int, +) -> Optional[dict]: + """ + Select the pricing tier for a request based on its total input token count. + + Alibaba Model Studio (Dashscope) tiered pricing is all-or-nothing: the tier is + chosen by the total input tokens of a single request and every token in the + request (input and output) is billed at that one tier's rate, rather than + graduated income-tax-style slicing. A tier matches when + ``range_start < input_tokens <= range_end`` (so a request of exactly + ``range_end`` tokens stays in the lower tier, matching the official + ``0 < Token <= 32K`` phrasing). Requests above the highest declared range fall + back to the last (most expensive) tier. + """ + if not tiered_pricing or input_tokens <= 0: + return None + + sorted_tiers = sorted(tiered_pricing, key=lambda t: t.get("range", [0, 0])[0]) + valid_tiers = [tier for tier in sorted_tiers if len(tier.get("range", [])) == 2] + if not valid_tiers: + return None + + matching = [tier for tier in valid_tiers if tier["range"][0] < input_tokens <= tier["range"][1]] + if matching: + return matching[0] + return valid_tiers[-1] + + +def tier_rate( + tier: dict, + cost_key: str, + fallback_cost_key: Optional[str] = None, +) -> float: + """Read a per-token rate from a tier, coercing YAML string costs to float.""" + raw = tier.get(cost_key) or tier.get(fallback_cost_key, 0) + return _coerce_cost_per_token(raw) diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index c039f0f43ee..33bf546c239 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -445,6 +445,7 @@ class PromptTokensDetailsResult(TypedDict): text_tokens: int audio_tokens: int image_tokens: int + video_tokens: int character_count: int image_count: int video_length_seconds: float @@ -473,6 +474,7 @@ def _parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult: ) audio_tokens = cast(Optional[int], getattr(usage.prompt_tokens_details, "audio_tokens", 0)) or 0 image_tokens = cast(Optional[int], getattr(usage.prompt_tokens_details, "image_tokens", 0)) or 0 + video_tokens = _coerce_token_count(getattr(usage.prompt_tokens_details, "video_tokens", 0)) character_count = ( cast( Optional[int], @@ -503,6 +505,7 @@ def _parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult: text_tokens=text_tokens, audio_tokens=audio_tokens, image_tokens=image_tokens, + video_tokens=video_tokens, character_count=character_count, image_count=image_count, video_length_seconds=float(video_length_seconds), @@ -515,6 +518,7 @@ class CompletionTokensDetailsResult(TypedDict): text_tokens: int reasoning_tokens: int image_tokens: int + video_tokens: int def _parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResult: @@ -546,12 +550,14 @@ def _parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsRes ) or 0 ) + video_tokens = _coerce_token_count(getattr(usage.completion_tokens_details, "video_tokens", 0)) return CompletionTokensDetailsResult( audio_tokens=audio_tokens, text_tokens=text_tokens, reasoning_tokens=reasoning_tokens, image_tokens=image_tokens, + video_tokens=video_tokens, ) @@ -586,6 +592,13 @@ def _calculate_input_cost( image_token_cost_key = "input_cost_per_token" prompt_cost += calculate_cost_component(model_info, image_token_cost_key, prompt_tokens_details["image_tokens"]) + ### VIDEO TOKEN COST + if prompt_tokens_details["video_tokens"]: + video_token_cost_key = "input_cost_per_video_token" + if model_info.get(video_token_cost_key) is None: + video_token_cost_key = "input_cost_per_token" + prompt_cost += calculate_cost_component(model_info, video_token_cost_key, prompt_tokens_details["video_tokens"]) + ### CACHE WRITING COST - Now uses tiered pricing if ( prompt_tokens_details["cache_creation_tokens"] @@ -698,6 +711,7 @@ def generic_cost_per_token( text_tokens=usage.prompt_tokens, audio_tokens=0, image_tokens=0, + video_tokens=0, character_count=0, image_count=0, video_length_seconds=0.0, @@ -716,13 +730,14 @@ def generic_cost_per_token( audio_tokens = prompt_tokens_details["audio_tokens"] cache_creation = prompt_tokens_details["cache_creation_tokens"] image_tokens = prompt_tokens_details["image_tokens"] + video_tokens = prompt_tokens_details["video_tokens"] # Check for double-counting: sum of details > prompt_tokens means overlap - total_details = text_tokens + cache_hit + audio_tokens + cache_creation + image_tokens + total_details = text_tokens + cache_hit + audio_tokens + cache_creation + image_tokens + video_tokens has_double_counting = cache_hit > 0 and total_details > usage.prompt_tokens if (text_tokens == 0 and prompt_tokens_details["image_count"] == 0) or has_double_counting: - text_tokens = usage.prompt_tokens - cache_hit - audio_tokens - cache_creation - image_tokens + text_tokens = usage.prompt_tokens - cache_hit - audio_tokens - cache_creation - image_tokens - video_tokens # Clamp to zero: inconsistent streaming usage if text_tokens < 0: text_tokens = 0 @@ -751,6 +766,7 @@ def generic_cost_per_token( audio_tokens = 0 reasoning_tokens = 0 image_tokens = 0 + video_tokens = 0 is_text_tokens_total = False if usage.completion_tokens_details is not None: completion_tokens_details = _parse_completion_tokens_details(usage) @@ -758,19 +774,20 @@ def generic_cost_per_token( text_tokens = completion_tokens_details["text_tokens"] reasoning_tokens = completion_tokens_details["reasoning_tokens"] image_tokens = completion_tokens_details["image_tokens"] + video_tokens = completion_tokens_details["video_tokens"] # Handle text_tokens calculation: # 1. If text_tokens is explicitly provided and > 0, use it - # 2. If there's a breakdown (reasoning/audio/image tokens), calculate text_tokens as the remainder + # 2. If there's a breakdown (reasoning/audio/image/video tokens), calculate text_tokens as the remainder # 3. If no breakdown at all, assume all completion_tokens are text_tokens - has_token_breakdown = image_tokens > 0 or audio_tokens > 0 or reasoning_tokens > 0 + has_token_breakdown = image_tokens > 0 or audio_tokens > 0 or reasoning_tokens > 0 or video_tokens > 0 if text_tokens == 0: if has_token_breakdown: # Calculate text tokens as remainder when we have a breakdown # This handles cases like OpenAI's reasoning models where text_tokens isn't provided text_tokens = max( 0, - usage.completion_tokens - reasoning_tokens - audio_tokens - image_tokens, + usage.completion_tokens - reasoning_tokens - audio_tokens - image_tokens - video_tokens, ) else: # No breakdown at all, all tokens are text tokens @@ -803,6 +820,14 @@ def generic_cost_per_token( ) completion_cost += float(image_tokens) * _output_cost_per_image_token + ## VIDEO COST + if not is_text_tokens_total and video_tokens and video_tokens > 0: + _output_cost_per_video_token = _get_cost_per_unit(model_info, "output_cost_per_video_token", None) + _output_cost_per_video_token = ( + _output_cost_per_video_token if _output_cost_per_video_token is not None else completion_base_cost + ) + completion_cost += float(video_tokens) * _output_cost_per_video_token + ## REGIONAL DATA-RESIDENCY UPLIFT # Applied as a flat multiplier across all token costs for the request # when the upstream is a regionalized OpenAI host (eu./us.api.openai.com). diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index 58107d9804b..47daf33824e 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -656,7 +656,7 @@ def convert_to_model_response_object( message: Optional[Message] = None finish_reason: Optional[str] = None - if _should_convert_tool_call_to_json_mode( + if tool_calls is not None and _should_convert_tool_call_to_json_mode( tool_calls=tool_calls, convert_tool_call_to_json_mode=convert_tool_call_to_json_mode, ): diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index c1635158d3b..f7ff4d6b16f 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -3626,6 +3626,7 @@ class BedrockImageProcessor: def _convert_to_bedrock_tool_call_invoke( tool_calls: list, + model: Optional[str] = None, ) -> List[BedrockContentBlock]: """ OpenAI tool invokes: @@ -3701,7 +3702,13 @@ def _convert_to_bedrock_tool_call_invoke( # cache_control applies to the whole original # tool call; attach after the last split block. if tool.get("cache_control", None) is not None: - _parts_list.append(BedrockContentBlock(cachePoint=CachePointBlock(type="default"))) + _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + {"cache_control": tool["cache_control"]}, + block_type="content_block", + model=model, + ) + if _cache_point_block is not None: + _parts_list.append(_cache_point_block) continue # Fallback: no objects extracted — use empty dict. arguments_dict = {} @@ -3712,8 +3719,13 @@ def _convert_to_bedrock_tool_call_invoke( # Check for cache_control and add a separate cachePoint block if tool.get("cache_control", None) is not None: - cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default")) - _parts_list.append(cache_point_block) + cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + {"cache_control": tool["cache_control"]}, + block_type="content_block", + model=model, + ) + if cache_point_block is not None: + _parts_list.append(cache_point_block) return _parts_list except Exception as e: raise Exception( @@ -4377,6 +4389,7 @@ class BedrockConverseMessagesProcessor: _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", + model=model, ) if _cache_point_block is not None: _parts.append(_cache_point_block) @@ -4384,7 +4397,7 @@ class BedrockConverseMessagesProcessor: elif message_block["content"] and isinstance(message_block["content"], str): _part = BedrockContentBlock(text=messages[msg_i]["content"]) _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( - message_block, block_type="content_block" + message_block, block_type="content_block", model=model ) user_content.append(_part) if _cache_point_block is not None: @@ -4416,22 +4429,27 @@ class BedrockConverseMessagesProcessor: tool_content.append(tool_call_result) # Check if we need to add a separate cachePoint block - has_cache_control = False + tool_msg_cache_control = None # Check for message-level cache_control if current_message.get("cache_control", None) is not None: - has_cache_control = True + tool_msg_cache_control = current_message["cache_control"] # Check for content-level cache_control in list content elif isinstance(current_message.get("content"), list): for content_element in current_message["content"]: if isinstance(content_element, dict) and content_element.get("cache_control", None) is not None: - has_cache_control = True + tool_msg_cache_control = content_element["cache_control"] break # Add a separate cachePoint block if cache_control is present - if has_cache_control: - cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default")) - tool_content.append(cache_point_block) + if tool_msg_cache_control is not None: + cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + {"cache_control": tool_msg_cache_control}, + block_type="content_block", + model=model, + ) + if cache_point_block is not None: + tool_content.append(cache_point_block) msg_i += 1 # Deduplicate toolResult blocks with the same toolUseId @@ -4509,6 +4527,7 @@ class BedrockConverseMessagesProcessor: _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", + model=model, ) if _cache_point_block is not None: assistants_parts.append(_cache_point_block) @@ -4520,14 +4539,14 @@ class BedrockConverseMessagesProcessor: # If content is empty/whitespace, skip it (don't add a placeholder) # Add cache point block for assistant string content _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( - assistant_message_block, block_type="content_block" + assistant_message_block, block_type="content_block", model=model ) if _cache_point_block is not None: assistant_content.append(_cache_point_block) _tool_calls = assistant_message_block.get("tool_calls", []) if _tool_calls: - assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls)) + assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls, model=model)) msg_i += 1 @@ -4745,6 +4764,7 @@ def _bedrock_converse_messages_pt( _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", + model=model, ) if _cache_point_block is not None: _parts.append(_cache_point_block) @@ -4752,7 +4772,7 @@ def _bedrock_converse_messages_pt( elif message_block["content"] and isinstance(message_block["content"], str): _part = BedrockContentBlock(text=messages[msg_i]["content"]) _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( - message_block, block_type="content_block" + message_block, block_type="content_block", model=model ) user_content.append(_part) if _cache_point_block is not None: @@ -4786,22 +4806,27 @@ def _bedrock_converse_messages_pt( tool_content.append(tool_call_result) # Check if we need to add a separate cachePoint block - has_cache_control = False + tool_msg_cache_control = None # Check for message-level cache_control if current_message.get("cache_control", None) is not None: - has_cache_control = True + tool_msg_cache_control = current_message["cache_control"] # Check for content-level cache_control in list content elif isinstance(current_message.get("content"), list): for content_element in current_message["content"]: if isinstance(content_element, dict) and content_element.get("cache_control", None) is not None: - has_cache_control = True + tool_msg_cache_control = content_element["cache_control"] break # Add a separate cachePoint block if cache_control is present - if has_cache_control: - cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default")) - tool_content.append(cache_point_block) + if tool_msg_cache_control is not None: + cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + {"cache_control": tool_msg_cache_control}, + block_type="content_block", + model=model, + ) + if cache_point_block is not None: + tool_content.append(cache_point_block) msg_i += 1 # Deduplicate toolResult blocks with the same toolUseId @@ -4882,6 +4907,7 @@ def _bedrock_converse_messages_pt( _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", + model=model, ) if _cache_point_block is not None: assistants_parts.append(_cache_point_block) @@ -4892,13 +4918,13 @@ def _bedrock_converse_messages_pt( assistant_content.append(BedrockContentBlock(text=_assistant_content)) # Add cache point block for assistant string content _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( - assistant_message_block, block_type="content_block" + assistant_message_block, block_type="content_block", model=model ) if _cache_point_block is not None: assistant_content.append(_cache_point_block) _tool_calls = assistant_message_block.get("tool_calls", []) if _tool_calls: - assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls)) + assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls, model=model)) msg_i += 1 @@ -5468,3 +5494,56 @@ def has_tool_with_name(tools: Any, tool_name: str) -> bool: elif tool.get("name") == tool_name: return True return False + + +def resolve_structured_messages( + messages: list[dict[str, Any]] | None, + request_kwargs: dict[str, Any], +) -> list[dict[str, Any]] | None: + """ + Normalize a request's messages to OpenAI-spec chat-completions shape, + regardless of which API surface produced them (chat completions, + Anthropic /v1/messages, Responses API ``input``, etc). + + Returns ``messages`` unchanged if already present. Otherwise dispatches + through the guardrail translation handlers (the same per-surface + conversion logic guardrails use) to convert e.g. Responses API ``input`` + into a message list. Returns ``None`` if no messages could be resolved. + """ + if messages: + return messages + + 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 + + mappings = load_guardrail_translation_mappings() + call_type: CallTypes | None = 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 diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index a1a070eb5b7..220d1caa3d2 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -1,5 +1,4 @@ import asyncio -import concurrent.futures import json from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union, cast @@ -25,9 +24,6 @@ if TYPE_CHECKING: else: CLIENT_CONNECTION_CLASS = Any -# Create a thread pool with a maximum of 10 threads -executor = concurrent.futures.ThreadPoolExecutor(max_workers=10) - class RealtimeEventNormalizer(Protocol): def should_drop(self, event: object) -> bool: ... @@ -315,13 +311,12 @@ class RealTimeStreaming: if self.session_tools or self.tool_calls: self.logging_obj.model_call_details["realtime_tools"] = self.session_tools self.logging_obj.model_call_details["realtime_tool_calls"] = self.tool_calls - ## ASYNC LOGGING # Route through the bounded logging worker (per-coroutine timeout + # concurrency cap) instead of a bare create_task, so a slow callback # can't leave suspended tasks pinning each call's response in memory. - GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(self.logging_obj.async_success_handler(self.messages)) - ## SYNC LOGGING - executor.submit(self.logging_obj.success_handler(self.messages)) + GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue( + self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True) + ) async def _send_to_backend(self, message: str) -> bool: """Send a message to the backend WebSocket. diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index cc9264e93f8..6e8429839ad 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -38,10 +38,45 @@ def redact_message_input_output_from_custom_logger( litellm_logging_obj: LiteLLMLoggingObject, result, custom_logger: CustomLogger ): if hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True: - return perform_redaction(litellm_logging_obj.model_call_details, result) + return perform_redaction(litellm_logging_obj.model_call_details, result, redact_streaming_responses=False) return result +def redact_streaming_responses_for_custom_logger(model_call_details: dict, custom_logger: CustomLogger) -> dict: + """ + Returns a copy of model_call_details whose streaming response entries are redacted deepcopies + when the custom logger has opted out of message logging. The shared model_call_details is left + untouched so other callbacks still receive the unredacted response. + """ + if not (hasattr(custom_logger, "message_logging") and custom_logger.message_logging is not True): + return model_call_details + redacted_entries = { + streaming_key: _redacted_streaming_response_copy(model_call_details[streaming_key]) + for streaming_key in ("complete_streaming_response", "async_complete_streaming_response") + if model_call_details.get(streaming_key) is not None + } + if not redacted_entries: + return model_call_details + return {**model_call_details, **redacted_entries} + + +def _redacted_streaming_response_copy(streaming_response): + redacted_response = copy.deepcopy(streaming_response) + _redact_streaming_response(redacted_response) + return redacted_response + + +def _redact_streaming_response(streaming_response): + if hasattr(streaming_response, "choices"): + for choice in streaming_response.choices: + _redact_choice_content(choice) + redact_vertex_ai_metadata_from_logged_object(streaming_response) + elif hasattr(streaming_response, "output"): + _redact_responses_api_output(streaming_response.output) + if hasattr(streaming_response, "reasoning") and streaming_response.reasoning is not None: + streaming_response.reasoning = None + + def _redact_choice_content(choice): """Helper to redact content in a choice (message or delta).""" if isinstance(choice, litellm.Choices): @@ -150,9 +185,13 @@ def _redact_model_response_dict_choices(choices, redacted_str: str): _redact_choice_content(choice) -def perform_redaction(model_call_details: dict, result): +def perform_redaction(model_call_details: dict, result, redact_streaming_responses: bool = True): """ Performs the actual redaction on the logging object and result. + + redact_streaming_responses=False skips the in-place redaction of the shared streaming + response entries; per-callback redaction hands each opted-out callback its own redacted + copy via redact_streaming_responses_for_custom_logger instead. """ # Redact model_call_details model_call_details["messages"] = [{"role": "user", "content": "redacted-by-litellm"}] @@ -162,17 +201,9 @@ def perform_redaction(model_call_details: dict, result): redact_vertex_ai_metadata_from_litellm_params(model_call_details) # Redact streaming response - if model_call_details.get("stream", False) is True and "complete_streaming_response" in model_call_details: - _streaming_response = model_call_details["complete_streaming_response"] - if hasattr(_streaming_response, "choices"): - for choice in _streaming_response.choices: - _redact_choice_content(choice) - redact_vertex_ai_metadata_from_logged_object(_streaming_response) - elif hasattr(_streaming_response, "output"): - _redact_responses_api_output(_streaming_response.output) - # Redact reasoning field in ResponsesAPIResponse - if hasattr(_streaming_response, "reasoning") and _streaming_response.reasoning is not None: - _streaming_response.reasoning = None + if redact_streaming_responses and model_call_details.get("stream", False) is True: + for _streaming_key in ("complete_streaming_response", "async_complete_streaming_response"): + _redact_streaming_response(model_call_details.get(_streaming_key)) # Redact result if result is not None: diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index daca48120cd..7861e13bae5 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -1,6 +1,8 @@ from collections.abc import Mapping from typing import Any, Dict, List, Optional, Set +from pydantic import BaseModel + from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER @@ -131,8 +133,59 @@ class SensitiveDataMasker: return masked_data + def mask(self, data: object) -> object: + if isinstance(data, Mapping): + return self.mask_dict(dict(data)) + if isinstance(data, list): + return self._mask_sequence( + data, + 0, + DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER, + None, + False, + ) + return data + _default_masker = SensitiveDataMasker() +_error_masker = SensitiveDataMasker(visible_prefix=4, visible_suffix=0) + + +def mask_sensitive_structure(data: object) -> object: + return _error_masker.mask(data) + + +def mask_credentials_in_payload(data: object) -> object: + """Return a copy of ``data`` where string values under sensitive-named keys + are masked but every other value (``None``, ``int``, ``float``, ``bool``, + ``bytes``, ``datetime``, tuples, sets, typed objects) is preserved by + identity, and dicts/lists are rebuilt structurally. + + Use this for logging payloads that carry response data through to + SpendLogs / OTel / Langfuse, where :meth:`SensitiveDataMasker.mask`'s + config-dump semantics (``None`` -> ``"None"``, tuples stringified, + objects flattened via ``__dict__``) would silently distort the record. + + Sensitive-key detection is delegated to the shared + :class:`SensitiveDataMasker` so pattern updates stay in one place. + """ + return _walk_payload(data, key_is_sensitive=False, depth=0) + + +def _walk_payload(node: object, key_is_sensitive: bool, depth: int) -> object: + if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER: + return node + if isinstance(node, Mapping): + return {k: _walk_payload(v, _default_masker.is_sensitive_key(k), depth + 1) for k, v in node.items()} + if isinstance(node, list): + return [_walk_payload(item, key_is_sensitive, depth + 1) for item in node] + if isinstance(node, tuple): + return tuple(_walk_payload(item, key_is_sensitive, depth + 1) for item in node) + if isinstance(node, BaseModel): + return _walk_payload(node.model_dump(), key_is_sensitive, depth) + if key_is_sensitive and isinstance(node, str) and node: + return _default_masker._mask_value(node) + return node def mask_sensitive_keys(data: Dict[str, Any], sensitive_fields: Set[str]) -> Dict[str, Any]: diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index deeee3b7daf..38bc68f2f78 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -7,6 +7,7 @@ from litellm.types.llms.openai import ( ChatCompletionAudioDelta, ) from litellm.types.utils import ( + CacheCreationTokenDetails, ChatCompletionAudioResponse, ChatCompletionMessageToolCall, Choices, @@ -541,6 +542,12 @@ class ChunkProcessor: web_search_requests: Optional[int] = None completion_tokens_details: Optional[CompletionTokensDetails] = None prompt_tokens_details: Optional[PromptTokensDetailsWrapper] = None + # Anthropic emits the cache-creation TTL breakdown (5m/1h split) only on + # the `message_start` event; the later `message_delta` carries the flat + # cache-creation count but drops the nested breakdown. prompt_tokens_details + # is last-wins, so without preserving this separately the 1h breakdown is + # lost and 1h cache writes get billed at the 5m rate. + cache_creation_token_details: Optional[CacheCreationTokenDetails] = None for chunk in chunks: usage_chunk: Optional[Usage] = None if "usage" in chunk: @@ -594,7 +601,18 @@ class ChunkProcessor: "web_search_requests", ) - prompt_tokens_details = usage_chunk_dict["prompt_tokens_details"] + prompt_tokens_details = cast( + Optional[PromptTokensDetailsWrapper], + usage_chunk_dict["prompt_tokens_details"], + ) + + cache_creation_token_details = self._capture_cache_creation_token_details( + prompt_tokens_details, cache_creation_token_details + ) + + prompt_tokens_details = self._attach_cache_creation_token_details( + prompt_tokens_details, cache_creation_token_details + ) completion_tokens = self._reset_anthropic_cursor_completion_tokens( chunks=chunks, @@ -613,6 +631,34 @@ class ChunkProcessor: prompt_tokens_details=prompt_tokens_details, ) + @staticmethod + def _capture_cache_creation_token_details( + prompt_tokens_details: Optional[PromptTokensDetailsWrapper], + current: Optional[CacheCreationTokenDetails], + ) -> Optional[CacheCreationTokenDetails]: + incoming = cast( + Optional[CacheCreationTokenDetails], + getattr(prompt_tokens_details, "cache_creation_token_details", None), + ) + if incoming is not None: + return incoming + return current + + @staticmethod + def _attach_cache_creation_token_details( + prompt_tokens_details: Optional[PromptTokensDetailsWrapper], + cache_creation_token_details: Optional[CacheCreationTokenDetails], + ) -> Optional[PromptTokensDetailsWrapper]: + if prompt_tokens_details is None or cache_creation_token_details is None: + return prompt_tokens_details + existing = cast( + Optional[CacheCreationTokenDetails], + getattr(prompt_tokens_details, "cache_creation_token_details", None), + ) + if existing is not None: + return prompt_tokens_details + return prompt_tokens_details.model_copy(update={"cache_creation_token_details": cache_creation_token_details}) + @staticmethod def _reset_anthropic_cursor_completion_tokens( chunks: list[dict[str, Any] | ModelResponse], diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 587a3a58a94..128ba0bf3ab 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -1884,7 +1884,7 @@ class CustomStreamWrapper: await self.fetch_stream() if is_async_iterable(self.completion_stream): - async for chunk in self.completion_stream: # type: ignore[union-attr] + async for chunk in self.completion_stream: # pyright: ignore[reportOptionalIterable] # is_async_iterable guard proves __aiter__ if chunk == "None" or chunk is None: continue # skip None chunks diff --git a/litellm/llms/a2a/chat/transformation.py b/litellm/llms/a2a/chat/transformation.py index c9623a817bf..113c000f352 100644 --- a/litellm/llms/a2a/chat/transformation.py +++ b/litellm/llms/a2a/chat/transformation.py @@ -10,7 +10,7 @@ import httpx from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException from litellm.types.llms.openai import AllMessageValues -from litellm.types.utils import Choices, Message, ModelResponse +from litellm.types.utils import Choices, Message, ModelResponse, Usage from ..common_utils import ( A2AError, @@ -312,6 +312,25 @@ class A2AConfig(BaseConfig): # Set ID from response model_response.id = response_json.get("id", str(uuid.uuid4())) + # A2A agents don't return token usage; estimate it so per-token pricing + # produces real cost and callers don't receive usage of 0/0/0. + try: + from litellm.utils import token_counter + + prompt_tokens = token_counter(model="gpt-3.5-turbo", messages=messages) + completion_tokens = token_counter(model="gpt-3.5-turbo", text=text, count_response_tokens=True) + setattr( + model_response, + "usage", + Usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=prompt_tokens + completion_tokens, + ), + ) + except Exception: # noqa: BLE001 - best-effort estimate; a tokenizer hiccup must not break the response + pass + return model_response def get_model_response_iterator( diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 4506c114208..7000c20d9c4 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -48,7 +48,10 @@ from litellm.types.utils import ( ) if TYPE_CHECKING: - from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + ModifyResponseException, + ) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.types.llms.anthropic_messages.anthropic_response import ( AnthropicMessagesResponse, @@ -70,6 +73,170 @@ class AnthropicMessagesHandler(BaseTranslation): super().__init__() self.adapter = LiteLLMAnthropicMessagesAdapter() + @staticmethod + def _build_streaming_usage_response( + responses_so_far: list[Any], + request_data: Optional[dict], + ) -> Optional[ModelResponse]: + chunks = tuple(response for response in responses_so_far if isinstance(response, (str, bytes))) + if not chunks: + return None + try: + return AnthropicPassthroughLoggingHandler._build_usage_only_response_from_chunks( + all_chunks=chunks, + model=str((request_data or {}).get("model") or ""), + ) + except (AttributeError, TypeError, ValueError): + return None + + def build_block_sse_chunks( + self, + exc: "ModifyResponseException", + stream_started: bool = False, + responses_so_far: Optional[list[Any]] = None, + ) -> list[bytes]: + """ + Build an Anthropic SSE sequence delivering the guardrail block message + and terminating the stream cleanly. + + - ``stream_started`` False (buffered / pre-stream): nothing has been + sent, so emit a complete standalone message (message_start -> + content_block_* -> message_delta -> message_stop) via + FakeAnthropicMessagesStreamIterator, the same converter the + /v1/messages pre-stream block handler uses. + - ``stream_started`` True (sampling / detect-only end-of-stream): real + chunks were already sent, so *continue* the in-progress message -- + close the open content block, append the block message as a new text + block, then end the message. Emitting a second ``message_start`` here + would make Anthropic clients reject the stream. + """ + if stream_started: + return self._block_continuation_chunks(exc, responses_so_far or []) + return self._standalone_block_chunks(exc) + + def _standalone_block_chunks(self, exc: "ModifyResponseException") -> list[bytes]: + import uuid + + from litellm.llms.anthropic.experimental_pass_through.messages.fake_stream_iterator import ( + FakeAnthropicMessagesStreamIterator, + ) + from litellm.llms.base_llm.guardrail_translation.utils import ( + blocked_response_usage, + ) + from litellm.types.utils import AnthropicMessagesResponse + + block_response = AnthropicMessagesResponse( + id=f"msg_{uuid.uuid4()}", + type="message", + role="assistant", + content=[{"type": "text", "text": exc.message}], + model=exc.model, + stop_reason="end_turn", + usage=blocked_response_usage(getattr(exc, "original_response", None)), + ) + return list(FakeAnthropicMessagesStreamIterator(response=block_response)) + + def _block_continuation_chunks(self, exc: "ModifyResponseException", responses_so_far: list[Any]) -> list[bytes]: + """Continue an already-started message: close the open content block, + append the block message as a new text block, then end the message -- + without a second message_start.""" + + from litellm.llms.base_llm.guardrail_translation.utils import ( + blocked_response_usage, + ) + + def _sse(event_type: str, payload: dict) -> bytes: + return f"event: {event_type}\ndata: {json.dumps(payload)}\n\n".encode() + + output_tokens = blocked_response_usage(getattr(exc, "original_response", None))["output_tokens"] + open_index, max_index = self._content_block_state(responses_so_far) + new_index = (max_index + 1) if max_index is not None else 0 + chunks: list[bytes] = [] + if open_index is not None: + chunks.append(_sse("content_block_stop", {"type": "content_block_stop", "index": open_index})) + chunks += [ + _sse( + "content_block_start", + { + "type": "content_block_start", + "index": new_index, + "content_block": {"type": "text", "text": ""}, + }, + ), + _sse( + "content_block_delta", + { + "type": "content_block_delta", + "index": new_index, + "delta": {"type": "text_delta", "text": exc.message}, + }, + ), + _sse("content_block_stop", {"type": "content_block_stop", "index": new_index}), + _sse( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": output_tokens}, + }, + ), + _sse("message_stop", {"type": "message_stop"}), + ] + return chunks + + @staticmethod + def _content_block_state( + responses_so_far: list[Any], + ) -> tuple[Optional[int], Optional[int]]: + """From the SSE chunks already sent to the client, return (open + content-block index or None, highest content-block index seen or None). + + A single streamed item may bundle multiple SSE events (raw bytes) or be + an already-parsed event dict, so every event across every item is + considered -- matching how ``get_streaming_string_so_far`` reads the + same stream.""" + open_indices: set[int] = set() + max_index: Optional[int] = None + for item in responses_so_far: + for data in AnthropicMessagesHandler._iter_sse_events(item): + event_type = data.get("type") + index = data.get("index") + if not isinstance(index, int): + continue + if event_type == "content_block_start": + open_indices.add(index) + max_index = index if max_index is None else max(max_index, index) + elif event_type == "content_block_stop": + open_indices.discard(index) + open_index = max(open_indices) if open_indices else None + return open_index, max_index + + @staticmethod + def _iter_sse_events(item: Any) -> list[dict]: + """Yield the event-data dicts in one stream chunk. + + Handles both formats this stream can carry (see + ``get_streaming_string_so_far``): raw SSE ``bytes`` -- which may bundle + several events separated by a blank line -- and an already-parsed event + ``dict``.""" + if isinstance(item, dict): + return [item] + if not isinstance(item, (bytes, bytearray)): + return [] + events: list[dict] = [] + for block in item.decode("utf-8", errors="replace").split("\n\n"): + for line in block.split("\n"): + line = line.strip() + if not line.startswith("data:"): + continue + try: + parsed = json.loads(line[len("data:") :].strip()) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + events.append(parsed) + return events + def _translate_to_openai(self, data: dict) -> ChatCompletionRequest: """Translate Anthropic request to OpenAI chat completion format.""" ( @@ -406,6 +573,8 @@ class AnthropicMessagesHandler(BaseTranslation): Get the string so far, check the apply guardrail to the string so far, and return the list of responses so far. """ + from litellm.integrations.custom_guardrail import ModifyResponseException + has_ended = self._check_streaming_has_ended(responses_so_far) if has_ended: # build the model response from the responses_so_far @@ -430,25 +599,35 @@ class AnthropicMessagesHandler(BaseTranslation): if tool_calls_list: guardrail_inputs["tool_calls"] = tool_calls_list - _guardrailed_inputs = ( - await guardrail_to_apply.apply_guardrail( # allow rejecting the response, if invalid + try: + _guardrailed_inputs = await guardrail_to_apply.apply_guardrail( inputs=guardrail_inputs, request_data=request_data if request_data is not None else {}, input_type="response", logging_obj=litellm_logging_obj, ) - ) + except ModifyResponseException as e: + if e.original_response is None: + e.original_response = built_response or self._build_streaming_usage_response( + responses_so_far, request_data + ) + raise else: verbose_proxy_logger.debug("Skipping output guardrail - model response has no choices") return responses_so_far string_so_far = self.get_streaming_string_so_far(responses_so_far) - _guardrailed_inputs = await guardrail_to_apply.apply_guardrail( # allow rejecting the response, if invalid - inputs={"texts": [string_so_far]}, - request_data=request_data if request_data is not None else {}, - input_type="response", - logging_obj=litellm_logging_obj, - ) + try: + _guardrailed_inputs = await guardrail_to_apply.apply_guardrail( + inputs={"texts": [string_so_far]}, + request_data=request_data if request_data is not None else {}, + input_type="response", + logging_obj=litellm_logging_obj, + ) + except ModifyResponseException as e: + if e.original_response is None: + e.original_response = self._build_streaming_usage_response(responses_so_far, request_data) + raise return responses_so_far def _prepare_request_data( diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 9721b797584..0ec1f3eae13 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -227,6 +227,10 @@ DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING = ( "Sonnet 4.6+, and Mythos Preview." ) +DROP_UNSUPPORTED_ADAPTIVE_THINKING_WARNING = ( + "Dropping adaptive `thinking` for model=%s: max_tokens is too small to fit the minimum thinking budget." +) + DROP_UNSUPPORTED_SPEED_WARNING = ( "Dropping unsupported `speed` for model=%s (drop_params=True). Fast mode is only supported on select Opus models." ) @@ -266,6 +270,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): def custom_llm_provider(self) -> Optional[str]: return "anthropic" + @property + def _resolved_provider(self) -> str: + return self.custom_llm_provider or "anthropic" + @classmethod def get_config(cls, *, model: Optional[str] = None): config = super().get_config() @@ -335,23 +343,26 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): return any(v in model_lower for v in ("opus-4-7", "opus_4_7", "opus-4.7", "opus_4.7")) @staticmethod - def _supports_effort_level(model: str, level: str) -> bool: + def _supports_effort_level(model: str, level: str, custom_llm_provider: str) -> bool: """Check ``supports_{level}_reasoning_effort`` in the model map.""" - return AnthropicConfig._supports_model_capability(model, f"supports_{level}_reasoning_effort") + return AnthropicConfig._supports_model_capability( + model, f"supports_{level}_reasoning_effort", custom_llm_provider + ) @staticmethod - def _validate_effort_for_model(model: str, effort: Optional[str]) -> Optional[str]: + def _validate_effort_for_model(model: str, effort: Optional[str], custom_llm_provider: str) -> Optional[str]: """Return ``None`` if ``effort`` is allowed on ``model``, else an error message.""" if effort == "max" and not ( - AnthropicConfig._is_adaptive_thinking_model(model) or AnthropicConfig._supports_effort_level(model, "max") + AnthropicConfig._is_adaptive_thinking_model(model, custom_llm_provider) + or AnthropicConfig._supports_effort_level(model, "max", custom_llm_provider) ): return f"effort='max' is not supported by this model. Got model: {model}" - if effort == "xhigh" and not AnthropicConfig._supports_effort_level(model, "xhigh"): + if effort == "xhigh" and not AnthropicConfig._supports_effort_level(model, "xhigh", custom_llm_provider): return f"effort='xhigh' is not supported by this model. Got model: {model}" return None @staticmethod - def _model_supports_effort_param(model: str) -> bool: + def _model_supports_effort_param(model: str, custom_llm_provider: str) -> bool: """Whether the model accepts ``output_config.effort`` at all. A model qualifies if its map entry advertises ``supports_output_config`` @@ -359,10 +370,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): signals: e.g. Claude Opus 4.5 supports ``output_config`` without advertising a non-default (max/xhigh) effort level. """ - if AnthropicConfig._supports_model_capability(model, "supports_output_config"): + if AnthropicConfig._supports_model_capability(model, "supports_output_config", custom_llm_provider): return True return any( - AnthropicConfig._supports_effort_level(model, level) + AnthropicConfig._supports_effort_level(model, level, custom_llm_provider) for level in ("low", "minimal", "medium", "high", "xhigh", "max") ) @@ -451,7 +462,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if ( "claude-3-7-sonnet" in model - or AnthropicConfig._is_adaptive_thinking_model(model) + or AnthropicConfig._is_adaptive_thinking_model(model, self._resolved_provider) or supports_reasoning( model=model, custom_llm_provider=self.custom_llm_provider, @@ -1159,11 +1170,13 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): def _map_reasoning_effort( reasoning_effort: Optional[Union[REASONING_EFFORT, str]], model: str, + custom_llm_provider: str, llm_provider: str = "anthropic", ) -> Optional[AnthropicThinkingParam]: + """Capability probes read the cost map under ``custom_llm_provider``; ``llm_provider`` only tags raised exceptions.""" if reasoning_effort is None or reasoning_effort == "none": return None - if AnthropicConfig._is_adaptive_thinking_model(model): + if AnthropicConfig._is_adaptive_thinking_model(model, custom_llm_provider): return AnthropicThinkingParam( type="adaptive", ) @@ -1211,6 +1224,23 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): llm_provider=llm_provider, ) + @staticmethod + def _cap_thinking_budget_to_max_tokens( + thinking: AnthropicThinkingParam, max_tokens: Optional[int] + ) -> Optional[AnthropicThinkingParam]: + """Cap a legacy ``thinking.budget_tokens`` below ``max_tokens`` (Anthropic + requires ``max_tokens > budget_tokens``). Returns the (possibly capped) + thinking dict, or ``None`` when ``max_tokens`` is too small to fit even the + minimum thinking budget and thinking should be dropped.""" + budget = thinking.get("budget_tokens") + if max_tokens is None or not isinstance(budget, int): + return thinking + if max_tokens <= ANTHROPIC_MIN_THINKING_BUDGET_TOKENS: + return None + if budget < max_tokens: + return thinking + return AnthropicThinkingParam(type=thinking.get("type", "enabled"), budget_tokens=max_tokens - 1) + def _extract_json_schema_from_response_format(self, value: Optional[dict]) -> Optional[dict]: if value is None: return None @@ -1411,24 +1441,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): output_key=param, ) elif param == "response_format" and isinstance(value, dict): - if any( - substring in model - for substring in { - "sonnet-4.5", - "sonnet-4-5", - "opus-4.1", - "opus-4-1", - "opus-4.5", - "opus-4-5", - "opus-4.6", - "opus-4-6", - "opus-4.7", - "opus-4-7", - "sonnet-4.6", - "sonnet-4-6", - "sonnet_4.6", - "sonnet_4_6", - } + if AnthropicConfig._supports_model_capability( + model, + "supports_native_structured_output", + self._resolved_provider, ): _output_format = self.map_response_format_to_anthropic_output_format(value) if _output_format is not None: @@ -1454,7 +1470,38 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ): optional_params["metadata"] = {"user_id": value} elif param == "thinking": - optional_params["thinking"] = value + if ( + isinstance(value, dict) + and value.get("type") == "adaptive" + and not AnthropicConfig._is_adaptive_thinking_model(model, self._resolved_provider) + ): + # Callers (e.g. Claude Code) send adaptive thinking + # unconditionally; translate it down to the legacy + # `thinking={type: enabled, budget_tokens}` interface a + # pre-4.6 model actually supports instead of forwarding a + # shape the model will reject. + max_tokens = non_default_params.get("max_completion_tokens") or non_default_params.get("max_tokens") + legacy_thinking = AnthropicConfig._map_reasoning_effort( + reasoning_effort="medium", + model=model, + custom_llm_provider=self._resolved_provider, + llm_provider=self._resolved_provider, + ) + capped_thinking = ( + AnthropicConfig._cap_thinking_budget_to_max_tokens(legacy_thinking, max_tokens) + if legacy_thinking is not None + else None + ) + if capped_thinking is not None: + optional_params["thinking"] = capped_thinking + else: + litellm.verbose_logger.warning( + DROP_UNSUPPORTED_ADAPTIVE_THINKING_WARNING, + model, + ) + optional_params.pop("thinking", None) + else: + optional_params["thinking"] = value elif param == "reasoning_effort": # Accept both string ("low") and dict ({"effort": "low", # "summary": "concise"}). The Responses->Chat parser keeps the @@ -1471,20 +1518,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): mapped_thinking = AnthropicConfig._map_reasoning_effort( reasoning_effort=effort_value, model=model, - llm_provider=self.custom_llm_provider or "anthropic", + custom_llm_provider=self._resolved_provider, + llm_provider=self._resolved_provider, ) if mapped_thinking is None: optional_params.pop("thinking", None) optional_params.pop("output_config", None) else: optional_params["thinking"] = mapped_thinking - if AnthropicConfig._is_adaptive_thinking_model(model): + if AnthropicConfig._is_adaptive_thinking_model(model, self._resolved_provider): mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(effort_value) if mapped_effort is None: AnthropicConfig._raise_invalid_reasoning_effort( model=model, value=effort_value, - llm_provider=self.custom_llm_provider or "anthropic", + llm_provider=self._resolved_provider, ) optional_params["output_config"] = {"effort": mapped_effort} elif param == "web_search_options" and isinstance(value, dict): @@ -1813,7 +1861,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): anthropic_messages = anthropic_messages_pt( model=model, messages=messages, - llm_provider=self.custom_llm_provider or "anthropic", + llm_provider=self._resolved_provider, ) except Exception as e: raise AnthropicError( @@ -1902,7 +1950,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): output_config = optional_params.get("output_config") if not output_config or not isinstance(output_config, dict): return - if litellm.drop_params is True and not self._model_supports_effort_param(model): + if litellm.drop_params is True and not self._model_supports_effort_param(model, self._resolved_provider): litellm.verbose_logger.warning( DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING, model, @@ -1916,14 +1964,14 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): raise litellm.exceptions.BadRequestError( message=(f"Invalid effort value: {effort!r}. Must be one of: 'high', 'medium', 'low', 'xhigh', 'max'"), model=model, - llm_provider=self.custom_llm_provider or "anthropic", + llm_provider=self._resolved_provider, ) - gate_error = self._validate_effort_for_model(model, effort) + gate_error = self._validate_effort_for_model(model, effort, self._resolved_provider) if gate_error is not None: raise litellm.exceptions.BadRequestError( message=gate_error, model=model, - llm_provider=self.custom_llm_provider or "anthropic", + llm_provider=self._resolved_provider, ) data["output_config"] = output_config diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index db540e5441d..e006662ec4d 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -289,6 +289,13 @@ class AnthropicModelInfo(BaseLLMModelInfo): status_code=400, ) + @staticmethod + def _strip_version_suffix(model: str) -> str: + at = model.rfind("@") + if at > 0: + return model[:at] + return model + @staticmethod def _model_map_lookup_candidates(model: str) -> List[str]: """Model-map keys to try for ``model``: the id itself, the same id with a @@ -324,6 +331,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): _DATED_RELEASE_SUFFIX_RE.sub("", cand), _DOTTED_VERSION_RE.sub(r"\1-\2", cand), _strip_bedrock_id_suffixes(cand), + AnthropicModelInfo._strip_version_suffix(cand), ) ) return list(dict.fromkeys((*primary, *normalized))) @@ -332,11 +340,15 @@ class AnthropicModelInfo(BaseLLMModelInfo): def _get_model_capability(model: str, key: str) -> Optional[bool]: """Read boolean capability ``key`` from the model map, or None when no entry declares it.""" + from litellm.utils import _get_bundled_model_cost_map + try: - for cand in AnthropicModelInfo._model_map_lookup_candidates(model): - value = litellm.model_cost.get(cand, {}).get(key) - if isinstance(value, bool): - return value + candidates = AnthropicModelInfo._model_map_lookup_candidates(model) + for model_cost in (litellm.model_cost, _get_bundled_model_cost_map()): + for cand in candidates: + value = model_cost.get(cand, {}).get(key) + if isinstance(value, bool): + return value except Exception: pass return None @@ -352,18 +364,43 @@ class AnthropicModelInfo(BaseLLMModelInfo): return value if isinstance(value, bool) else None @staticmethod - def _supports_model_capability(model: str, key: str) -> bool: - """Check a boolean capability ``key`` in the model map. + def _get_provider_resolved_capability(model: str, key: str, custom_llm_provider: str) -> Optional[bool]: + """Resolve boolean capability ``key`` for ``model`` under the caller's provider. - Strips bedrock/vertex prefixes so a provider-routed Claude still - resolves to the Anthropic model-map entry. + Returns the flag when the provider-aware lookup resolves ``model`` to an + entry (or fallback rule) that sets it explicitly, and ``None`` when the + model does not resolve under that provider or the resolved entry has no + opinion on ``key``. + """ + from litellm.utils import _get_model_info_helper + + try: + resolved_model, resolved_provider, _, _ = litellm.get_llm_provider( + model=model, custom_llm_provider=custom_llm_provider + ) + value = _get_model_info_helper(model=resolved_model, custom_llm_provider=resolved_provider).get(key) + except Exception: # noqa: BLE001 # _get_model_info_helper raises bare Exception for unmapped models + return None + return value if isinstance(value, bool) else None + + @staticmethod + def _supports_model_capability(model: str, key: str, custom_llm_provider: str) -> bool: + """Check a boolean capability ``key`` in the model map under the caller's provider. + + The provider-aware lookup is authoritative when it resolves an explicit flag, + so ``key: false`` on the provider-namespaced entry wins over every fallback. + Otherwise ``_supports_factory``'s provider-level fallbacks and the raw + model-map walk remain as backstops for alias forms the lookup misses. """ from litellm.utils import _supports_factory + resolved = AnthropicModelInfo._get_provider_resolved_capability(model, key, custom_llm_provider) + if resolved is not None: + return resolved try: if _supports_factory( model=model, - custom_llm_provider="anthropic", + custom_llm_provider=custom_llm_provider, key=key, ): return True @@ -372,17 +409,24 @@ class AnthropicModelInfo(BaseLLMModelInfo): return AnthropicModelInfo._get_model_capability(model, key) is True @staticmethod - def _is_adaptive_thinking_model(model: str) -> bool: + def _is_adaptive_thinking_model(model: str, custom_llm_provider: str) -> bool: """Whether ``model`` uses adaptive thinking (``output_config.effort``). The model cost map is authoritative: an explicit ``supports_adaptive_thinking`` - entry, or a ``fallback_generalizations`` rule for unknown Claude models. The - version gate (>= 4.6, including provider-prefixed Bedrock/Vertex ids that map to - no exact entry) lives entirely in that declarative rule, not here. + entry resolved under ``custom_llm_provider``, or a ``fallback_generalizations`` + rule for unknown Claude models. The version gate (>= 4.6, including + provider-prefixed Bedrock/Vertex ids that map to no exact entry) lives entirely + in that declarative rule, not here. """ - return AnthropicModelInfo._supports_model_capability(model, "supports_adaptive_thinking") + return AnthropicModelInfo._supports_model_capability(model, "supports_adaptive_thinking", custom_llm_provider) - def is_effort_used(self, optional_params: Optional[dict], model: Optional[str] = None) -> bool: + def is_effort_used( + self, + optional_params: Optional[dict], + model: Optional[str] = None, + *, + custom_llm_provider: str, + ) -> bool: """ Check if effort parameter is being used and requires a beta header. @@ -394,7 +438,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): return False # Claude 4.6+ models use output_config as a stable API feature — no beta header needed - if model and self._is_adaptive_thinking_model(model): + if model and self._is_adaptive_thinking_model(model, custom_llm_provider): return False # Check if reasoning_effort is provided for Claude Opus 4.5 @@ -475,6 +519,8 @@ class AnthropicModelInfo(BaseLLMModelInfo): prompt_caching_set: bool = False, file_id_used: bool = False, mcp_server_used: bool = False, + *, + custom_llm_provider: str, ) -> List[str]: """ Get list of common beta headers based on the features that are active. @@ -487,7 +533,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): betas = [] # Detect features - effort_used = self.is_effort_used(optional_params, model) + effort_used = self.is_effort_used(optional_params, model, custom_llm_provider=custom_llm_provider) if effort_used: betas.append(ANTHROPIC_EFFORT_BETA_HEADER) # effort-2025-11-24 @@ -643,7 +689,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): tool_search_used = self.is_tool_search_used(tools=tools) programmatic_tool_calling_used = self.is_programmatic_tool_calling_used(tools=tools) input_examples_used = self.is_input_examples_used(tools=tools) - effort_used = self.is_effort_used(optional_params=optional_params, model=model) + effort_used = self.is_effort_used(optional_params=optional_params, model=model, custom_llm_provider="anthropic") code_execution_tool_used = self.is_code_execution_tool_used(tools=tools) container_with_skills_used = self.is_container_with_skills_used(optional_params=optional_params) user_anthropic_beta_headers = self._get_user_anthropic_beta_headers( diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 812e0f62c96..7299fc16897 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -78,7 +78,7 @@ async def _prepare_context_managed_request( system: Optional[Any], context_management_spec: Any, litellm_metadata: Optional[Dict], - drop_params: Optional[bool], + additional_drop_params: Optional[list[str]], llm_router: Any, user_api_key_auth: Any = None, ) -> Optional[PolyfillResult]: @@ -95,7 +95,7 @@ async def _prepare_context_managed_request( # silently drop intermediate turns. polyfill_will_run = _polyfill_will_run( context_management_spec=context_management_spec, - drop_params=drop_params, + additional_drop_params=additional_drop_params, ) if polyfill_will_run: @@ -117,7 +117,7 @@ async def _prepare_context_managed_request( system=working_system, context_management_spec=context_management_spec, litellm_metadata=litellm_metadata, - drop_params=drop_params, + additional_drop_params=additional_drop_params, llm_router=llm_router, user_api_key_auth=user_api_key_auth, ) @@ -143,18 +143,19 @@ async def _prepare_context_managed_request( def _polyfill_will_run( *, context_management_spec: Any, - drop_params: Optional[bool], + additional_drop_params: Optional[list[str]], ) -> bool: """Return True when ``compact_20260112`` will run via the polyfill dispatcher. - Mirrors the gating in ``_run_polyfill_if_enabled``: an empty spec or - effective ``drop_params`` short-circuits the polyfill. The pre-processing - skip only applies when the dispatcher will actually invoke - ``apply_compact_20260112`` (which has its own compaction-block slicing). + Mirrors the gating in ``_run_polyfill_if_enabled``: an empty spec or an + explicit ``context_management`` entry in ``additional_drop_params`` + short-circuits the polyfill. The pre-processing skip only applies when the + dispatcher will actually invoke ``apply_compact_20260112`` (which has its + own compaction-block slicing). """ edits = _normalize_spec_edits( context_management_spec=context_management_spec, - drop_params=drop_params, + additional_drop_params=additional_drop_params, ) if edits is None: return False @@ -169,7 +170,7 @@ def _polyfill_will_run( def _spec_has_non_compact_edits( *, context_management_spec: Any, - drop_params: Optional[bool], + additional_drop_params: Optional[list[str]], ) -> bool: """Return True when the spec includes edits other than ``compact_20260112``. @@ -180,7 +181,7 @@ def _spec_has_non_compact_edits( """ edits = _normalize_spec_edits( context_management_spec=context_management_spec, - drop_params=drop_params, + additional_drop_params=additional_drop_params, ) if edits is None: return False @@ -195,10 +196,22 @@ def _spec_has_non_compact_edits( ) +def _context_management_explicitly_dropped(additional_drop_params: Optional[list[str]]) -> bool: + """True when the caller opted out of context_management via ``additional_drop_params``. + + ``drop_params`` deliberately does NOT gate the polyfill: ``context_management`` + is a LiteLLM-supported param (native on Anthropic, polyfilled elsewhere), and + ``drop_params`` only exists to drop genuinely unsupported params. + """ + if not isinstance(additional_drop_params, list): + return False + return "context_management" in additional_drop_params + + def _normalize_spec_edits( *, context_management_spec: Any, - drop_params: Optional[bool], + additional_drop_params: Optional[list[str]], ) -> Optional[List[Dict[str, Any]]]: """Return the normalized ``edits`` list, or ``None`` if the polyfill won't run. @@ -208,8 +221,7 @@ def _normalize_spec_edits( if not context_management_spec: return None - effective_drop_params = drop_params if drop_params is not None else litellm.drop_params - if effective_drop_params: + if _context_management_explicitly_dropped(additional_drop_params): return None from litellm.llms.anthropic.experimental_pass_through.context_management.dispatcher import ( @@ -230,22 +242,23 @@ async def _run_polyfill_if_enabled( system: Optional[Any], context_management_spec: Any, litellm_metadata: Optional[Dict], - drop_params: Optional[bool], + additional_drop_params: Optional[list[str]], llm_router: Any, user_api_key_auth: Any = None, ) -> Optional[PolyfillResult]: """Run the async context_management polyfill if a spec is present. - Returns ``None`` when the spec is empty or drop_params is on. Raises - ``AnthropicContextManagementError`` so the /v1/messages endpoint can - emit an Anthropic-format 400. All other exceptions are best-effort - swallowed (matches v0 behavior). + Returns ``None`` when the spec is empty or ``context_management`` is + listed in ``additional_drop_params`` (the explicit opt-out; ``drop_params`` + does not disable the polyfill because context_management is a supported + param). Raises ``AnthropicContextManagementError`` so the /v1/messages + endpoint can emit an Anthropic-format 400. All other exceptions are + best-effort swallowed (matches v0 behavior). """ if not context_management_spec: return None - effective_drop_params = drop_params if drop_params is not None else litellm.drop_params - if effective_drop_params: + if _context_management_explicitly_dropped(additional_drop_params): return None try: @@ -274,7 +287,7 @@ async def _run_polyfill_if_enabled( # emits an Anthropic-format error. if _spec_has_non_compact_edits( context_management_spec=context_management_spec, - drop_params=drop_params, + additional_drop_params=additional_drop_params, ): raise AnthropicContextManagementError( status_code=500, @@ -533,7 +546,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: ) -> Union[AnthropicMessagesResponse, AsyncIterator[Any], Iterator[bytes]]: """Handle non-Anthropic models asynchronously using the adapter""" context_management = kwargs.pop("context_management", None) - drop_params: Optional[bool] = kwargs.get("drop_params", None) + additional_drop_params: Optional[list[str]] = kwargs.get("additional_drop_params", None) litellm_router = kwargs.pop("litellm_router", None) if litellm_router is None: try: @@ -555,7 +568,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: system=system, context_management_spec=context_management, litellm_metadata=proxy_litellm_metadata, - drop_params=drop_params, + additional_drop_params=additional_drop_params, llm_router=litellm_router, user_api_key_auth=user_api_key_auth, ) @@ -661,7 +674,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: # ``compact_20260112`` editor can ``await`` the summarization model); # bridge to it via ``run_async_function``. context_management = kwargs.pop("context_management", None) - drop_params: Optional[bool] = kwargs.get("drop_params", None) + additional_drop_params: Optional[list[str]] = kwargs.get("additional_drop_params", None) # Deliberately do NOT auto-attach the proxy ``llm_router`` here: # ``run_async_function`` spawns a new event loop in a worker thread # to bridge to the async dispatcher, but the proxy router's httpx @@ -696,7 +709,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: system=system, context_management_spec=context_management, litellm_metadata=proxy_litellm_metadata, - drop_params=drop_params, + additional_drop_params=additional_drop_params, llm_router=litellm_router, user_api_key_auth=user_api_key_auth, ) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 44c367ee805..f02333c34c8 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -13,14 +13,18 @@ from typing import ( List, Literal, Optional, + get_args, ) +from typing_extensions import assert_never + from litellm._logging import verbose_logger from litellm._uuid import uuid from litellm.types.llms.anthropic import ( AppliedEdit, CompactionBlock, ContextManagementResponse, + StreamingContentBlockDeltaType, UsageDelta, UsageIteration, ) @@ -30,6 +34,23 @@ if TYPE_CHECKING: from litellm.types.utils import ModelResponseStream +_STREAMING_DELTA_TYPES = frozenset(get_args(StreamingContentBlockDeltaType)) + + +def _delta_payload_field(delta_type: StreamingContentBlockDeltaType) -> str: + match delta_type: + case "text_delta": + return "text" + case "input_json_delta": + return "partial_json" + case "thinking_delta": + return "thinking" + case "signature_delta": + return "signature" + case _: + assert_never(delta_type) + + class _CombinedChunkSplitter: """ Splits a streaming chunk that carries BOTH response content and a @@ -458,12 +479,15 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # 3. If the trigger chunk carries delta content, queue it # so the first delta of the new block is not silently dropped. - if self._trigger_delta_has_content(processed_chunk): + if self._delta_has_content(processed_chunk): self.chunk_queue.append(processed_chunk) self.sent_content_block_finish = False return self.chunk_queue.popleft() + if processed_chunk["type"] == "content_block_delta" and not self._delta_has_content(processed_chunk): + continue + if processed_chunk["type"] == "message_delta" and self.sent_content_block_finish is False: # Queue both the content_block_stop and the message_delta self.chunk_queue.append( @@ -670,13 +694,18 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # 3. If the trigger chunk carries delta content, queue it # so the first delta of the new block is not silently dropped. - if self._trigger_delta_has_content(processed_chunk): + if self._delta_has_content(processed_chunk): self.chunk_queue.append(processed_chunk) # Reset state for new block self.sent_content_block_finish = False return self.chunk_queue.popleft() + if processed_chunk["type"] == "content_block_delta" and not self._delta_has_content( + processed_chunk + ): + continue + if processed_chunk["type"] == "message_delta" and self.sent_content_block_finish is False: # Queue both the content_block_stop and the holding chunk self.chunk_queue.append( @@ -808,20 +837,33 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): self.current_content_block_index += 1 @staticmethod - def _trigger_delta_has_content(processed_chunk: Dict[str, Any]) -> bool: - """Return True if a translated trigger chunk carries a non-empty - ``content_block_delta`` payload that must be re-emitted after a - block transition. + def _delta_has_content(processed_chunk: Dict[str, Any]) -> bool: + """Return True if a translated chunk carries a non-empty + ``content_block_delta`` payload. - When an upstream chunk both *triggers* a new content block (its type - differs from the active block) and *carries* delta content, that - content belongs to the new block. The synthesized - ``content_block_start`` only ever carries an empty body — see + Gates every ``content_block_delta`` emission. An empty delta carries + no information, and the translate fallback types empty deltas as + ``text_delta`` regardless of the active block's type — emitting one + into an open ``thinking`` block (e.g. Bedrock Converse sends an empty + reasoning delta mid-block) crashes strict Anthropic SDK clients with + "Content block is not a text block". + + Also gates re-emission after a block transition: when an upstream + chunk both *triggers* a new content block (its type differs from the + active block) and *carries* delta content, that content belongs to + the new block. The synthesized ``content_block_start`` only ever + carries an empty body — see ``_translate_streaming_openai_chunk_to_anthropic_content_block``, which returns an empty ``TextBlock``/``ToolUseBlock``/thinking block — so the trigger chunk's delta must be re-queued or the first token of the new block (the first non-empty text/thinking delta, or bundled tool arguments) is silently dropped. + + Delta types outside ``StreamingContentBlockDeltaType`` — the closed + set the translate layer can produce — are treated as empty. The + per-type payload lookup is exhaustively matched against that set in + ``_delta_payload_field``, so extending the translate layer with a new + delta type fails type-checking here until it is handled. """ if processed_chunk.get("type") != "content_block_delta": return False @@ -829,15 +871,9 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): if not isinstance(delta, dict): return False delta_type = delta.get("type") - if delta_type == "text_delta": - return bool(delta.get("text")) - if delta_type == "input_json_delta": - return bool(delta.get("partial_json")) - if delta_type == "thinking_delta": - return bool(delta.get("thinking")) - if delta_type == "signature_delta": - return bool(delta.get("signature")) - return False + if delta_type not in _STREAMING_DELTA_TYPES: + return False + return bool(delta.get(_delta_payload_field(delta_type))) def _should_start_new_content_block(self, chunk: "ModelResponseStream") -> bool: """ diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 4c981dd36b3..cd75eed2e6e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -104,6 +104,7 @@ from litellm.types.llms.anthropic import ( ContextManagementResponse, MessageBlockDelta, MessageDelta, + StreamingContentBlockDeltaType, UsageDelta, UsageIteration, ) @@ -1423,7 +1424,7 @@ class LiteLLMAnthropicMessagesAdapter: def _translate_streaming_openai_chunk_to_anthropic( self, choices: List[Union[OpenAIStreamingChoice, StreamingChoices]] ) -> Tuple[ - Literal["text_delta", "input_json_delta", "thinking_delta", "signature_delta"], + StreamingContentBlockDeltaType, Union[ ContentTextBlockDelta, ContentJsonBlockDelta, 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 index cb37725d79c..4bf36a0d5c6 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py @@ -193,6 +193,14 @@ class AgenticAnthropicStreamingIterator: raise StopAsyncIteration + async def aclose(self) -> None: + from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import ( + aclose_if_supported, + ) + + await aclose_if_supported(self._inner) + await aclose_if_supported(self._follow_up_iterator) + async def _process_agentic_hooks(self) -> None: """Rebuild the Anthropic response from collected SSE bytes and call hooks.""" if self._hook_processing_done: diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index effd7dda6a0..dd983f0c344 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -148,6 +148,7 @@ async def _try_websearch_short_circuit( tools: Optional[List[Dict]], custom_llm_provider: Optional[str], stream: Optional[bool], + kwargs: Optional[dict] = None, ) -> Optional[Union[AnthropicMessagesResponse, AsyncIterator]]: """ Attempt to short-circuit a web-search-only request. @@ -177,6 +178,7 @@ async def _try_websearch_short_circuit( messages=messages, tools=tools, custom_llm_provider=custom_llm_provider, + kwargs=kwargs, ) if response is not None: anthropic_response = cast(AnthropicMessagesResponse, response) @@ -292,6 +294,7 @@ async def anthropic_messages( tools=tools, custom_llm_provider=custom_llm_provider, stream=original_stream, + kwargs={**kwargs, "metadata": metadata}, ) if short_circuit_response is not None: return short_circuit_response diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py index 6c72b7a3e00..79faa39c7a2 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py @@ -17,7 +17,9 @@ How it works: import uuid from typing import Any, AsyncIterator, Dict, List, Optional, Union +import litellm import litellm.constants as _c +from litellm.litellm_core_utils.url_utils import validate_url from litellm.llms.anthropic.common_utils import strip_advisor_blocks_from_messages from litellm.types.llms.anthropic_messages.anthropic_response import ( AnthropicMessagesResponse, @@ -76,16 +78,7 @@ class AdvisorOrchestrationHandler(MessagesInterceptor): raise ValueError("advisor tool definition must include a 'model' field specifying the advisor model") _raw_max_uses = advisor_tool.get("max_uses") max_uses: int = ADVISOR_MAX_USES if _raw_max_uses is None else int(_raw_max_uses) - # Optional routing overrides for the advisor sub-call (e.g. proxy routing). - # If not set in the tool definition, litellm resolves from env vars. - # The advisor tool is caller-controlled; only honor a client-supplied - # api_base/api_key when the proxy has enabled clientside credentials, - # otherwise let litellm resolve from server config. - advisor_api_key: Optional[str] = None - advisor_api_base: Optional[str] = None - if _allow_client_side_advisor_credentials(): - advisor_api_key = advisor_tool.get("api_key") - advisor_api_base = advisor_tool.get("api_base") + advisor_api_key, advisor_api_base = _resolve_advisor_credentials(advisor_tool) # Build the synthetic tool definition the provider will receive. synthetic_advisor_tool = _make_synthetic_advisor_tool() @@ -186,6 +179,49 @@ def _allow_client_side_advisor_credentials() -> bool: return general_settings.get("allow_client_side_credentials") is True +def _resolve_advisor_credentials(advisor_tool: dict) -> tuple[Optional[str], Optional[str]]: + """Resolve the (api_key, api_base) override for the advisor sub-call. + + A caller-supplied ``api_base`` is only honored alongside a caller-supplied + ``api_key``: without one, ``AnthropicModelInfo.get_auth_header()`` falls + back to the proxy's own Anthropic credentials, which would then be sent to + the caller-chosen ``api_base``. A caller-supplied ``api_base`` is also + required to be https with TLS verification on, and SSRF-validated so it + can't target a private/internal/cloud-metadata address, mirroring + ``proxy.auth.auth_utils.check_complete_credentials``. https with TLS + verification is required because ``validate_url`` only rewrites the + connection to a DNS-pinned IP for http, or for https with + ``litellm.ssl_verify`` disabled; otherwise it returns the URL unchanged + and relies on certificate validation to block DNS rebinding, so this + closes the same gap without threading the pinned URL through the whole + ``anthropic_messages()`` call chain. + """ + if not _allow_client_side_advisor_credentials(): + return None, None + api_key: Optional[str] = advisor_tool.get("api_key") + api_base: Optional[str] = advisor_tool.get("api_base") + if api_base is None: + return api_key, None + if not api_key: + raise ValueError( + "advisor tool definition sets 'api_base' without 'api_key'. A " + "caller-supplied api_base is only honored alongside a " + "caller-supplied api_key, so the proxy's own credentials are " + "never sent to a caller-chosen destination." + ) + if not api_base.startswith("https://"): + raise ValueError(f"advisor tool definition sets 'api_base'={api_base!r}, which must use the https scheme.") + if getattr(litellm, "ssl_verify", True) is False: + raise ValueError( + "advisor tool definition sets 'api_base' but the proxy has TLS verification " + "disabled (litellm.ssl_verify=False), so a caller-supplied api_base can't be " + "safely validated against DNS rebinding." + ) + if getattr(litellm, "user_url_validation", True): + validate_url(api_base) + return api_key, api_base + + def _make_synthetic_advisor_tool() -> Dict: """Build a regular tool definition the executor provider can understand.""" return { diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py index 2357960f716..5f2b23d7eca 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py @@ -1,8 +1,13 @@ import asyncio import json from datetime import datetime -from typing import Any, AsyncIterator, List, Union +from typing import Any, AsyncIterator, List, Protocol, Union, runtime_checkable +import httpx +from pydantic import TypeAdapter +from typing_extensions import TypedDict + +from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy.pass_through_endpoints.success_handler import ( PassThroughEndpointLogging, @@ -12,6 +17,93 @@ from litellm.types.utils import GenericStreamingChunk, ModelResponseStream GLOBAL_PASS_THROUGH_SUCCESS_HANDLER_OBJ = PassThroughEndpointLogging() +INCOMPLETE_STREAM_ERROR_MESSAGE = ( + "Provider stream ended before emitting a message_stop event; " + "the response is incomplete and any partial content (e.g. tool_use input JSON) may be truncated." +) + + +def _is_message_stop_chunk(chunk: object) -> bool: + if isinstance(chunk, dict): + return chunk.get("type") == "message_stop" + if isinstance(chunk, (bytes, bytearray)): + return any(line == b"event: message_stop" for line in chunk.splitlines()) + return False + + +def _is_provider_error_chunk(chunk: object) -> bool: + if isinstance(chunk, dict): + return chunk.get("type") == "error" + if isinstance(chunk, (bytes, bytearray)): + return any(line == b"event: error" for line in chunk.splitlines()) + return False + + +def _is_terminal_stream_chunk(chunk: object) -> bool: + return _is_message_stop_chunk(chunk) or _is_provider_error_chunk(chunk) + + +def _incomplete_stream_error_sse_event() -> bytes: + payload = json.dumps( + { + "type": "error", + "error": {"type": "api_error", "message": INCOMPLETE_STREAM_ERROR_MESSAGE}, + } + ) + return f"event: error\ndata: {payload}\n\n".encode() + + +class AnthropicMessagesStreamHiddenParams(TypedDict): + additional_headers: dict[str, str] + + +@runtime_checkable +class SupportsAclose(Protocol): + async def aclose(self) -> None: ... + + +async def aclose_if_supported(stream: object) -> None: + if isinstance(stream, SupportsAclose): + await stream.aclose() + + +_RESPONSE_HEADERS_ADAPTER: TypeAdapter[dict[str, str]] = TypeAdapter(dict[str, str]) + + +def anthropic_messages_stream_hidden_params( + response_headers: httpx.Headers, +) -> AnthropicMessagesStreamHiddenParams: + return AnthropicMessagesStreamHiddenParams( + additional_headers=_RESPONSE_HEADERS_ADAPTER.validate_python(process_response_headers(response_headers)) + ) + + +class AnthropicMessagesStreamingResponse: + """ + Wraps the /v1/messages SSE byte stream so upstream provider response + headers (e.g. Bedrock's x-amzn-requestid / x-amzn-trace-id) survive as + ``_hidden_params["additional_headers"]``, which the proxy forwards to + clients as ``llm_provider-*`` response headers. Bare async generators + cannot carry attributes, so header context was previously dropped. + """ + + def __init__( + self, + completion_stream: AsyncIterator[bytes], + hidden_params: AnthropicMessagesStreamHiddenParams, + ) -> None: + self.completion_stream = completion_stream + self._hidden_params = hidden_params + + def __aiter__(self) -> "AnthropicMessagesStreamingResponse": + return self + + async def __anext__(self) -> bytes: + return await self.completion_stream.__anext__() + + async def aclose(self) -> None: + await aclose_if_supported(self.completion_stream) + class BaseAnthropicMessagesStreamingIterator: """ @@ -102,13 +194,18 @@ class BaseAnthropicMessagesStreamingIterator: This method provides the common logic for both Anthropic and Bedrock implementations. """ collected_chunks = [] + saw_terminal_event = False async for chunk in completion_stream: if self.completion_start_time is None: self.completion_start_time = datetime.now() + saw_terminal_event = saw_terminal_event or _is_terminal_stream_chunk(chunk) encoded_chunk = self._convert_chunk_to_sse_format(chunk) collected_chunks.append(encoded_chunk) yield encoded_chunk + if not saw_terminal_event: + yield _incomplete_stream_error_sse_event() + # Handle logging after all chunks are processed await self._handle_streaming_logging(collected_chunks) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index e78802a1587..05679bf39ab 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -32,8 +32,22 @@ from ...common_utils import ( DEFAULT_ANTHROPIC_API_VERSION = "2023-06-01" +DROP_UNSUPPORTED_ADAPTIVE_EFFORT_WARNING = ( + "Dropping adaptive `thinking`/`output_config.effort` for model=%s: the model " + "does not support extended thinking, or max_tokens is too small to fit the " + "minimum thinking budget." +) + class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): + @property + def custom_llm_provider(self) -> Optional[str]: + return "anthropic" + + @property + def _resolved_provider(self) -> str: + return self.custom_llm_provider or "anthropic" + def get_supported_anthropic_messages_params(self, model: str) -> list: return [ "messages", @@ -174,7 +188,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): return headers, api_base @staticmethod - def _translate_reasoning_effort_to_anthropic(model: str, optional_params: Dict) -> None: + def _translate_reasoning_effort_to_anthropic(model: str, optional_params: Dict, custom_llm_provider: str) -> None: """Map OpenAI-style ``reasoning_effort`` to native Anthropic params. Caller-supplied ``thinking`` / ``output_config`` win over the alias. @@ -191,7 +205,11 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): return try: - mapped_thinking = AnthropicConfig._map_reasoning_effort(reasoning_effort=reasoning_effort, model=model) + mapped_thinking = AnthropicConfig._map_reasoning_effort( + reasoning_effort=reasoning_effort, + model=model, + custom_llm_provider=custom_llm_provider, + ) except _BadRequestError as e: raise AnthropicError(message=str(e.message), status_code=400) @@ -201,7 +219,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): return optional_params.setdefault("thinking", mapped_thinking) - if AnthropicModelInfo._is_adaptive_thinking_model(model): + if AnthropicModelInfo._is_adaptive_thinking_model(model, custom_llm_provider): mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(reasoning_effort) if mapped_effort is None: raise AnthropicError( @@ -212,7 +230,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): ), status_code=400, ) - gate_error = AnthropicConfig._validate_effort_for_model(model, mapped_effort) + gate_error = AnthropicConfig._validate_effort_for_model(model, mapped_effort, custom_llm_provider) if gate_error is not None: raise AnthropicError(message=gate_error, status_code=400) existing_output_config = optional_params.get("output_config") @@ -222,13 +240,15 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): optional_params["output_config"] = existing_output_config @staticmethod - def _translate_legacy_thinking_for_adaptive_model(model: str, optional_params: Dict) -> None: + def _translate_legacy_thinking_for_adaptive_model( + model: str, optional_params: Dict, custom_llm_provider: str + ) -> None: """Translate legacy ``thinking.type=enabled`` to adaptive for 4.6/4.7. Caller-provided ``output_config.effort`` is never overridden. """ from litellm.llms.anthropic.chat.transformation import AnthropicConfig - if not AnthropicModelInfo._is_adaptive_thinking_model(model): + if not AnthropicModelInfo._is_adaptive_thinking_model(model, custom_llm_provider): return thinking = optional_params.get("thinking") if not isinstance(thinking, dict) or thinking.get("type") != "enabled": @@ -236,7 +256,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): budget = int(thinking.get("budget_tokens") or 0) if budget >= DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET and ( - AnthropicConfig._supports_effort_level(model, "xhigh") + AnthropicConfig._supports_effort_level(model, "xhigh", custom_llm_provider) ): effort = "xhigh" elif budget >= DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET: @@ -253,6 +273,138 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): existing_output_config.setdefault("effort", effort) optional_params["output_config"] = existing_output_config + @staticmethod + def _translate_adaptive_effort_for_non_adaptive_model( + model: str, optional_params: Dict, max_tokens: Optional[int], custom_llm_provider: str + ) -> None: + """Translate the 4.6+ adaptive-thinking interface (``thinking.type=adaptive`` + and/or ``output_config.effort``) down to what an older Anthropic model + supports. Clients like Claude Code send this interface unconditionally, so + without translation it reaches a pre-4.6 model and Anthropic rejects it with + "This model does not support the effort parameter". + + The reshape is silent, matching how the messages path already strips + unsupported ``output_config`` for older models (bedrock invoke, issue + #22797): the goal is to keep the request working, not to fail it. + + ``thinking.type=adaptive`` and ``output_config.effort`` are independent + capabilities. Adaptive thinking needs ``supports_adaptive_thinking`` (4.6+); + ``output_config.effort`` needs ``supports_output_config``, which some + non-adaptive models (e.g. Claude Opus 4.5) advertise on its own. So the two + are handled separately: + + - Adaptive-thinking models (4.6+): both are native, left untouched. + - ``supports_output_config`` but non-adaptive (Opus 4.5): keep + ``output_config.effort`` (native), only drop the unsupported adaptive + ``thinking`` block. When adaptive thinking is being dropped and the + effort level itself isn't supported by the model (e.g. ``xhigh``/``max`` + on Opus 4.5, which only accepts low/medium/high, while ``xhigh`` is + Claude Code's default), fall through to the legacy translation below + instead of forwarding a level Anthropic would reject. Effort-only + requests are always left untouched: provider subclasses own their level + normalization (bedrock clamps ``xhigh`` to the model's ceiling after + this base transform runs). + - Thinking-capable but neither (``supports_reasoning``, e.g. Haiku/Sonnet + 4.5): map effort to legacy ``thinking={type: enabled, budget_tokens}`` via + ``AnthropicConfig._map_reasoning_effort``, capped below ``max_tokens`` + (Anthropic requires ``max_tokens > budget_tokens``) and dropped when + ``max_tokens`` can't fit even the minimum budget. + - No reasoning support: ``thinking`` is dropped. + + For the last two, only the consumed ``effort`` key is removed from + ``output_config``; any residual (e.g. ``format``) is left for provider + subclasses to handle. + """ + from litellm.exceptions import BadRequestError as _BadRequestError + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + if AnthropicConfig._is_adaptive_thinking_model(model, custom_llm_provider): + return + + output_config = optional_params.get("output_config") + thinking = optional_params.get("thinking") + effort = output_config.get("effort") if isinstance(output_config, dict) else None + adaptive_thinking = isinstance(thinking, dict) and thinking.get("type") == "adaptive" + if effort is None and not adaptive_thinking: + return + + # Models that natively accept `output_config.effort` but are not adaptive (Claude Opus 4.5). + # Keep the native effort and only drop the adaptive `thinking` block, which these models + # reject. Effort-only requests pass through so provider subclasses (bedrock/vertex) keep + # owning level clamping; an adaptive request only stays here when its effort level is one + # the model supports, otherwise it falls through to the legacy budget translation below. + if AnthropicConfig._model_supports_effort_param(model, custom_llm_provider) and ( + not adaptive_thinking + or AnthropicConfig._validate_effort_for_model(model, effort, custom_llm_provider) is None + ): + if adaptive_thinking: + optional_params.pop("thinking", None) + return + + supports_thinking = AnthropicModelInfo._supports_model_capability( + model, "supports_reasoning", custom_llm_provider + ) + try: + legacy_thinking = ( + AnthropicConfig._map_reasoning_effort( + reasoning_effort=effort or "medium", + model=model, + custom_llm_provider=custom_llm_provider, + ) + if supports_thinking + else None + ) + except _BadRequestError as e: + raise AnthropicError(message=str(e.message), status_code=400) + capped_thinking = ( + AnthropicConfig._cap_thinking_budget_to_max_tokens(legacy_thinking, max_tokens) + if legacy_thinking is not None + else None + ) + + if capped_thinking is not None: + optional_params["thinking"] = capped_thinking + else: + verbose_logger.warning(DROP_UNSUPPORTED_ADAPTIVE_EFFORT_WARNING, model) + optional_params.pop("thinking", None) + + if isinstance(output_config, dict) and "effort" in output_config: + residual = {k: v for k, v in output_config.items() if k != "effort"} + if residual: + optional_params["output_config"] = residual + else: + optional_params.pop("output_config", None) + + @staticmethod + def _drop_incompatible_temperature_for_thinking( + model: str, optional_params: dict, custom_llm_provider: str + ) -> None: + """Anthropic rejects any ``temperature`` other than 1 while extended thinking + is enabled ("temperature may only be set to 1 when thinking is enabled"). + + Clients like Claude Code send ``thinking``/``output_config.effort`` together + with a pinned ``temperature`` (e.g. the safety classifier uses ``temperature=0`` + for determinism). When the request lands on a non-adaptive model, the effort + interface is reshaped above into legacy ``thinking={type: enabled}`` (or kept + as ``output_config.effort`` on Opus 4.5), and the leftover ``temperature`` would + 400. Preserving the thinking the caller asked for wins over an unhonorable + sampling value (Anthropic forces ``temperature=1`` under thinking regardless), + so drop it and let the API default apply. + + Adaptive models (4.6+) own this natively and are left untouched. + """ + if AnthropicModelInfo._is_adaptive_thinking_model(model, custom_llm_provider): + return + temperature = optional_params.get("temperature") + if temperature is None or temperature == 1: + return + thinking = optional_params.get("thinking") + output_config = optional_params.get("output_config") + thinking_enabled = isinstance(thinking, dict) and thinking.get("type") == "enabled" + effort_enabled = isinstance(output_config, dict) and output_config.get("effort") is not None + if thinking_enabled or effort_enabled: + optional_params.pop("temperature", None) + def transform_anthropic_messages_request( self, model: str, @@ -277,11 +429,26 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): self._translate_reasoning_effort_to_anthropic( model=model, optional_params=anthropic_messages_optional_request_params, + custom_llm_provider=self._resolved_provider, ) self._translate_legacy_thinking_for_adaptive_model( model=model, optional_params=anthropic_messages_optional_request_params, + custom_llm_provider=self._resolved_provider, + ) + + self._translate_adaptive_effort_for_non_adaptive_model( + model=model, + optional_params=anthropic_messages_optional_request_params, + max_tokens=max_tokens, + custom_llm_provider=self._resolved_provider, + ) + + self._drop_incompatible_temperature_for_thinking( + model=model, + optional_params=anthropic_messages_optional_request_params, + custom_llm_provider=self._resolved_provider, ) system_param = anthropic_messages_optional_request_params.get("system") 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 1a052f457c5..172e54de98e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -198,14 +198,16 @@ class LiteLLMAnthropicToResponsesAPIAdapter: @staticmethod def translate_tool_choice_to_responses_api( tool_choice: AnthropicMessagesToolChoice, - ) -> Dict[str, Any]: + ) -> Union[str, dict[str, Any]]: """Convert Anthropic tool_choice to Responses API tool_choice.""" tc_type = tool_choice.get("type") if tc_type == "any": - return {"type": "required"} + return "required" elif tc_type == "tool": return {"type": "function", "name": tool_choice.get("name", "")} - return {"type": "auto"} + elif tc_type == "none": + return "none" + return "auto" @staticmethod def translate_context_management_to_responses_api( diff --git a/litellm/llms/anthropic/files/transformation.py b/litellm/llms/anthropic/files/transformation.py index cf12ad9ab32..0fa01e09492 100644 --- a/litellm/llms/anthropic/files/transformation.py +++ b/litellm/llms/anthropic/files/transformation.py @@ -39,6 +39,7 @@ from ..common_utils import AnthropicError, AnthropicModelInfo ANTHROPIC_FILES_API_BASE = "https://api.anthropic.com" ANTHROPIC_FILES_BETA_HEADER = "files-api-2025-04-14" +ANTHROPIC_MESSAGE_BATCH_ID_PREFIX = "msgbatch_" class AnthropicFilesConfig(BaseFilesConfig): @@ -258,6 +259,8 @@ class AnthropicFilesConfig(BaseFilesConfig): file_id = file_content_request.get("file_id") api_base = AnthropicModelInfo.get_api_base(litellm_params.get("api_base")) or ANTHROPIC_FILES_API_BASE encoded_file_id = encode_url_path_segment(file_id, field_name="file_id") + if file_id.startswith(ANTHROPIC_MESSAGE_BATCH_ID_PREFIX): + return f"{api_base.rstrip('/')}/v1/messages/batches/{encoded_file_id}/results", {} return f"{api_base.rstrip('/')}/v1/files/{encoded_file_id}/content", {} def transform_file_content_response( diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index fef9b7d0154..d0b0dbb070d 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -205,7 +205,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): ######################################################### ########## DELETE RESPONSE API TRANSFORMATION ############## ######################################################### - def _construct_url_for_response_id_in_path(self, api_base: str, response_id: str) -> str: + def _construct_url_for_response_id_in_path(self, api_base: str, response_id: str, path_suffix: str = "") -> str: """ Constructs a URL for the API request with the response_id in the path. """ @@ -218,14 +218,14 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): # Remove trailing slash if present to avoid double slashes path = parsed_url.path.rstrip("/") encoded_response_id = encode_url_path_segment(response_id, field_name="response_id") - new_path = f"{path}/{encoded_response_id}" + new_path = f"{path}/{encoded_response_id}{path_suffix}" # Reconstruct the URL with all original components but with the modified path constructed_url = urlunparse( ( parsed_url.scheme, # http, https parsed_url.netloc, # domain name, port - new_path, # path with response_id added + new_path, parsed_url.params, # parameters parsed_url.query, # query string parsed_url.fragment, # fragment @@ -288,7 +288,9 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): limit: int = 20, order: Literal["asc", "desc"] = "desc", ) -> Tuple[str, Dict]: - url = self._construct_url_for_response_id_in_path(api_base=api_base, response_id=response_id) + "/input_items" + url = self._construct_url_for_response_id_in_path( + api_base=api_base, response_id=response_id, path_suffix="/input_items" + ) params: Dict[str, Any] = {} if after is not None: params["after"] = after @@ -322,27 +324,8 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): This function handles URLs with query parameters by inserting the response_id at the correct location (before any query parameters). """ - from urllib.parse import urlparse, urlunparse - - # Parse the URL to separate its components - parsed_url = urlparse(api_base) - - # Insert the response_id and /cancel at the end of the path component - # Remove trailing slash if present to avoid double slashes - path = parsed_url.path.rstrip("/") - encoded_response_id = encode_url_path_segment(response_id, field_name="response_id") - new_path = f"{path}/{encoded_response_id}/cancel" - - # Reconstruct the URL with all original components but with the modified path - cancel_url = urlunparse( - ( - parsed_url.scheme, # http, https - parsed_url.netloc, # domain name, port - new_path, # path with response_id and /cancel added - parsed_url.params, # parameters - parsed_url.query, # query string - parsed_url.fragment, # fragment - ) + cancel_url = self._construct_url_for_response_id_in_path( + api_base=api_base, response_id=response_id, path_suffix="/cancel" ) data: Dict = {} diff --git a/litellm/llms/azure_ai/anthropic/messages_transformation.py b/litellm/llms/azure_ai/anthropic/messages_transformation.py index 1de18701a2f..8cee35989af 100644 --- a/litellm/llms/azure_ai/anthropic/messages_transformation.py +++ b/litellm/llms/azure_ai/anthropic/messages_transformation.py @@ -21,6 +21,10 @@ class AzureAnthropicMessagesConfig(AnthropicMessagesConfig): and Azure endpoint format. """ + @property + def custom_llm_provider(self) -> Optional[str]: + return "azure_ai" + def should_strip_billing_metadata(self) -> bool: return True diff --git a/litellm/llms/azure_ai/ocr/common_utils.py b/litellm/llms/azure_ai/ocr/common_utils.py index d1d5b80b78d..14b77338fd7 100644 --- a/litellm/llms/azure_ai/ocr/common_utils.py +++ b/litellm/llms/azure_ai/ocr/common_utils.py @@ -13,6 +13,17 @@ if TYPE_CHECKING: from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig +def is_azure_document_intelligence_model(model: str) -> bool: + """Whether an azure_ai OCR model routes to Azure Document Intelligence. + + Azure AI exposes two OCR services on the same provider; the sub-route in the + model name (`azure_ai/doc-intelligence/`) selects Document Intelligence + over Mistral OCR. This is the single source of truth for that routing decision. + """ + lowered = model.lower() + return "doc-intelligence" in lowered or "documentintelligence" in lowered + + def get_azure_ai_ocr_config(model: str) -> Optional["BaseOCRConfig"]: """ Determine which Azure AI OCR configuration to use based on the model name. @@ -41,7 +52,7 @@ def get_azure_ai_ocr_config(model: str) -> Optional["BaseOCRConfig"]: from litellm.llms.azure_ai.ocr.transformation import AzureAIOCRConfig # Check for Azure Document Intelligence models - if "doc-intelligence" in model or "documentintelligence" in model: + if is_azure_document_intelligence_model(model): verbose_logger.debug(f"Routing {model} to Azure Document Intelligence OCR config") return AzureDocumentIntelligenceOCRConfig() diff --git a/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py b/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py index c67703f64d7..7d915892a28 100644 --- a/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py +++ b/litellm/llms/azure_ai/ocr/document_intelligence/transformation.py @@ -15,6 +15,7 @@ from typing import Any, Dict from urllib.parse import quote import httpx +from pydantic import BaseModel from litellm._logging import verbose_logger from litellm.litellm_core_utils.url_utils import SSRFError, assert_same_origin @@ -38,6 +39,30 @@ from litellm.secret_managers.main import get_secret_str AZURE_DOCUMENT_INTELLIGENCE_API_KEY_ENV_VAR = "AZURE_DOCUMENT_INTELLIGENCE_API_KEY" +class AzureDocumentIntelligenceLine(BaseModel): + content: str | None = None + + +class AzureDocumentIntelligencePage(BaseModel): + pageNumber: int | None = None + width: float | None = None + height: float | None = None + unit: str | None = None + lines: tuple[AzureDocumentIntelligenceLine, ...] = () + + +class AzureDocumentIntelligenceAnalyzeResult(BaseModel): + content: str | None = None + pages: tuple[AzureDocumentIntelligencePage, ...] = () + tables: list[dict[str, object]] | None = None + keyValuePairs: list[dict[str, object]] | None = None + + +class AzureDocumentIntelligenceOperation(BaseModel): + status: str | None = None + analyzeResult: AzureDocumentIntelligenceAnalyzeResult | None = None + + class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): """ Azure Document Intelligence OCR transformation configuration. @@ -67,11 +92,14 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): (1-based, e.g. "1-3,5,7-9"). To keep the public request shape aligned with Mistral OCR, callers pass `pages` using Mistral semantics — a list of 0-based integers — or a pre-formatted - Azure-style string. Other Mistral-specific params (e.g. + Azure-style string. Azure DI also exposes a `features` query + parameter enabling add-on capabilities (e.g. "keyValuePairs", + "languages"), passed as a list of feature names or a + comma-separated string. Other Mistral-specific params (e.g. `include_image_base64`) are not supported by Azure DI and are ignored during transformation. """ - return ["pages"] + return ["pages", "features"] def map_ocr_params( self, @@ -85,16 +113,18 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): Translates Mistral-style `pages` (list[int], 0-based) into Azure's `pages` query string (1-based, e.g. "1,2,3" or "1-3,5"). A raw string that already matches Azure's format is passed through - unchanged. + unchanged. `features` (list[str] or comma-separated string) is + normalized into Azure's comma-joined `features` query string. """ pages = non_default_params.get("pages") - if pages is None: - return optional_params - - normalized = self._normalize_pages_param(pages) - if normalized: - optional_params["pages"] = normalized - return optional_params + features = non_default_params.get("features") + normalized_pages = self._normalize_pages_param(pages) if pages is not None else "" + normalized_features = self._normalize_features_param(features) if features is not None else "" + return { + **optional_params, + **({"pages": normalized_pages} if normalized_pages else {}), + **({"features": normalized_features} if normalized_features else {}), + } @staticmethod def _normalize_pages_param(pages: Any) -> str: @@ -140,6 +170,39 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): raise ValueError("`pages` must be a list[int] (0-based, Mistral-style) or a string like '1-3,5,7-9'.") + @staticmethod + def _normalize_features_param(features: object) -> str: + """ + Convert a caller-provided `features` value to Azure DI's query-string + form (comma-joined feature names, e.g. "keyValuePairs,languages"). + + Accepted inputs: + - list[str]: feature names like ["keyValuePairs", "languages"]. + - str: a single feature name or comma-separated names. + """ + invalid_features_error = ValueError( + f"Invalid `features` for Azure Document Intelligence: {features!r}. " + f"Expected a list of feature names or a comma-separated string like " + f"'keyValuePairs' or 'keyValuePairs,languages'." + ) + + if isinstance(features, str): + raw_tokens = features.split(",") + elif isinstance(features, list): + if len(features) == 0: + return "" + raw_tokens = [feature for feature in features if isinstance(feature, str)] + if len(raw_tokens) != len(features): + raise invalid_features_error + else: + raise invalid_features_error + + tokens = tuple(token.strip() for token in raw_tokens) + feature_pattern = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") + if not all(feature_pattern.match(token) for token in tokens): + raise invalid_features_error + return ",".join(tokens) + def validate_environment( self, headers: Dict, @@ -228,13 +291,15 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): f"?api-version={AZURE_DOCUMENT_INTELLIGENCE_API_VERSION}" ) - # Azure DI accepts `pages` as a query param (1-based, e.g. "1-3,5"). + # Azure DI accepts `pages` (1-based, e.g. "1-3,5") and `features` + # (comma-joined names, e.g. "keyValuePairs") as query params. # `optional_params` has already been normalized in `map_ocr_params`. pages = optional_params.get("pages") if optional_params else None - if pages: - url += f"&pages={quote(str(pages), safe=',-')}" + features = optional_params.get("features") if optional_params else None + pages_query = f"&pages={quote(str(pages), safe=',-')}" if pages else "" + features_query = f"&features={quote(str(features), safe=',')}" if features else "" - return url + return f"{url}{pages_query}{features_query}" def _extract_base64_from_data_uri(self, data_uri: str) -> str: """ @@ -328,27 +393,15 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): return OCRRequestData(data=data, files=None) - def _extract_page_markdown(self, page_data: Dict[str, Any]) -> str: - """ - Extract text from Azure DI page and format as markdown. - - Azure DI provides text in 'lines' array. We concatenate them with newlines. - - Args: - page_data: Azure DI page object - - Returns: - Markdown-formatted text - """ - lines = page_data.get("lines", []) - if not lines: - return "" - - # Extract text content from each line - text_lines = [line.get("content", "") for line in lines] - - # Join with newlines to preserve structure - return "\n".join(text_lines) + def _transform_azure_page(self, azure_page: AzureDocumentIntelligencePage) -> OCRPage: + page_number = azure_page.pageNumber if azure_page.pageNumber is not None else 1 + markdown = "\n".join(line.content or "" for line in azure_page.lines) + dimensions = self._convert_dimensions( + width=azure_page.width if azure_page.width is not None else 8.5, + height=azure_page.height if azure_page.height is not None else 11, + unit=azure_page.unit if azure_page.unit is not None else "inch", + ) + return OCRPage(index=page_number - 1, markdown=markdown, dimensions=dimensions) def _convert_dimensions(self, width: float, height: float, unit: str) -> OCRPageDimensions: """ @@ -526,6 +579,52 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): retry_after = self._get_retry_after(response=response) await asyncio.sleep(retry_after) + def _get_polling_target(self, raw_response: httpx.Response) -> tuple[str, Dict[str, str]]: + operation_url = raw_response.headers.get("Operation-Location") + if not operation_url: + raise ValueError("Azure Document Intelligence returned 202 but no Operation-Location header found") + + # Reject cross-origin polling URLs — the auth headers + # below would otherwise leak to whatever URL the upstream + # (or an attacker-controlled upstream) returns. VERIA-51. + try: + assert_same_origin(operation_url, str(raw_response.request.url)) + except SSRFError as ssrf_err: + raise ValueError(f"Azure Document Intelligence: rejected polling URL ({ssrf_err})") + + poll_headers = {"Ocp-Apim-Subscription-Key": raw_response.request.headers.get("Ocp-Apim-Subscription-Key", "")} + return operation_url, poll_headers + + def _transform_completed_response(self, model: str, raw_response: httpx.Response) -> OCRResponse: + """ + Transform a completed Azure Document Intelligence analyze operation + into the Mistral OCR response shape, preserving Azure-native + `analyzeResult` fields (`content`, `tables`, `keyValuePairs`) as + top-level response fields. + """ + operation = AzureDocumentIntelligenceOperation.model_validate(raw_response.json()) + + verbose_logger.debug(f"Azure Document Intelligence response status: {operation.status}") + + if operation.status != "succeeded": + raise ValueError(f"Azure Document Intelligence analysis failed with status: {operation.status}") + + analyze_result = ( + operation.analyzeResult if operation.analyzeResult is not None else AzureDocumentIntelligenceAnalyzeResult() + ) + mistral_pages = [self._transform_azure_page(azure_page) for azure_page in analyze_result.pages] + usage_info = OCRUsageInfo(pages_processed=len(mistral_pages), doc_size_bytes=None) + + return OCRResponse( + pages=mistral_pages, + model=model, + usage_info=usage_info, + object="ocr", + content=analyze_result.content, + tables=analyze_result.tables, + keyValuePairs=analyze_result.keyValuePairs, + ) + def transform_ocr_response( self, model: str, @@ -552,11 +651,13 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): "unit": "inch", "lines": [{"content": "text", "boundingBox": [...]}] } - ] + ], + "tables": [...], + "keyValuePairs": [...] } } - Mistral OCR format: + Mistral OCR format (with Azure-native fields preserved): { "pages": [ { @@ -567,7 +668,10 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): ], "model": "azure_ai/doc-intelligence/prebuilt-layout", "usage_info": {"pages_processed": 1}, - "object": "ocr" + "object": "ocr", + "content": "Full document text...", + "tables": [...], + "keyValuePairs": [...] } Args: @@ -578,86 +682,17 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): Returns: OCRResponse in Mistral format """ - try: - # Check if we got 202 Accepted (async operation started) - if raw_response.status_code == 202: - verbose_logger.debug("Azure DI returned 202 Accepted, polling operation...") + if raw_response.status_code != 202: + return self._transform_completed_response(model=model, raw_response=raw_response) - # Get Operation-Location header - operation_url = raw_response.headers.get("Operation-Location") - if not operation_url: - raise ValueError("Azure Document Intelligence returned 202 but no Operation-Location header found") - - # Reject cross-origin polling URLs — the auth headers - # below would otherwise leak to whatever URL the upstream - # (or an attacker-controlled upstream) returns. VERIA-51. - try: - assert_same_origin(operation_url, str(raw_response.request.url)) - except SSRFError as ssrf_err: - raise ValueError(f"Azure Document Intelligence: rejected polling URL ({ssrf_err})") - - # Get headers for polling (need auth) - poll_headers = { - "Ocp-Apim-Subscription-Key": raw_response.request.headers.get("Ocp-Apim-Subscription-Key", "") - } - - # Get timeout from kwargs or use default - timeout_secs = AZURE_OPERATION_POLLING_TIMEOUT - - # Poll until operation completes - raw_response = self._poll_operation_sync( - operation_url=operation_url, - headers=poll_headers, - timeout_secs=timeout_secs, - ) - - # Now parse the completed response - response_json = raw_response.json() - - verbose_logger.debug(f"Azure Document Intelligence response status: {response_json.get('status')}") - - # Check if request succeeded - status = response_json.get("status") - if status != "succeeded": - raise ValueError(f"Azure Document Intelligence analysis failed with status: {status}") - - # Extract analyze result - analyze_result = response_json.get("analyzeResult", {}) - azure_pages = analyze_result.get("pages", []) - - # Transform pages to Mistral format - mistral_pages = [] - for azure_page in azure_pages: - page_number = azure_page.get("pageNumber", 1) - index = page_number - 1 # Convert to 0-based index - - # Extract markdown text - markdown = self._extract_page_markdown(azure_page) - - # Convert dimensions - width = azure_page.get("width", 8.5) - height = azure_page.get("height", 11) - unit = azure_page.get("unit", "inch") - dimensions = self._convert_dimensions(width=width, height=height, unit=unit) - - # Build OCR page - ocr_page = OCRPage(index=index, markdown=markdown, dimensions=dimensions) - mistral_pages.append(ocr_page) - - # Build usage info - usage_info = OCRUsageInfo(pages_processed=len(mistral_pages), doc_size_bytes=None) - - # Return Mistral OCR response - return OCRResponse( - pages=mistral_pages, - model=model, - usage_info=usage_info, - object="ocr", - ) - - except Exception as e: - verbose_logger.error(f"Error parsing Azure Document Intelligence response: {e}") - raise e + verbose_logger.debug("Azure DI returned 202 Accepted, polling operation...") + operation_url, poll_headers = self._get_polling_target(raw_response) + completed_response = self._poll_operation_sync( + operation_url=operation_url, + headers=poll_headers, + timeout_secs=AZURE_OPERATION_POLLING_TIMEOUT, + ) + return self._transform_completed_response(model=model, raw_response=completed_response) async def async_transform_ocr_response( self, @@ -680,81 +715,14 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig): Returns: OCRResponse in Mistral format """ - try: - # Check if we got 202 Accepted (async operation started) - if raw_response.status_code == 202: - verbose_logger.debug("Azure DI returned 202 Accepted, polling operation (async)...") + if raw_response.status_code != 202: + return self._transform_completed_response(model=model, raw_response=raw_response) - # Get Operation-Location header - operation_url = raw_response.headers.get("Operation-Location") - if not operation_url: - raise ValueError("Azure Document Intelligence returned 202 but no Operation-Location header found") - - # Reject cross-origin polling URLs (see sync path). VERIA-51. - try: - assert_same_origin(operation_url, str(raw_response.request.url)) - except SSRFError as ssrf_err: - raise ValueError(f"Azure Document Intelligence: rejected polling URL ({ssrf_err})") - - # Get headers for polling (need auth) - poll_headers = { - "Ocp-Apim-Subscription-Key": raw_response.request.headers.get("Ocp-Apim-Subscription-Key", "") - } - - # Get timeout from kwargs or use default - timeout_secs = AZURE_OPERATION_POLLING_TIMEOUT - - # Poll until operation completes (async) - raw_response = await self._poll_operation_async( - operation_url=operation_url, - headers=poll_headers, - timeout_secs=timeout_secs, - ) - - # Now parse the completed response - response_json = raw_response.json() - - verbose_logger.debug(f"Azure Document Intelligence response status: {response_json.get('status')}") - - # Check if request succeeded - status = response_json.get("status") - if status != "succeeded": - raise ValueError(f"Azure Document Intelligence analysis failed with status: {status}") - - # Extract analyze result - analyze_result = response_json.get("analyzeResult", {}) - azure_pages = analyze_result.get("pages", []) - - # Transform pages to Mistral format - mistral_pages = [] - for azure_page in azure_pages: - page_number = azure_page.get("pageNumber", 1) - index = page_number - 1 # Convert to 0-based index - - # Extract markdown text - markdown = self._extract_page_markdown(azure_page) - - # Convert dimensions - width = azure_page.get("width", 8.5) - height = azure_page.get("height", 11) - unit = azure_page.get("unit", "inch") - dimensions = self._convert_dimensions(width=width, height=height, unit=unit) - - # Build OCR page - ocr_page = OCRPage(index=index, markdown=markdown, dimensions=dimensions) - mistral_pages.append(ocr_page) - - # Build usage info - usage_info = OCRUsageInfo(pages_processed=len(mistral_pages), doc_size_bytes=None) - - # Return Mistral OCR response - return OCRResponse( - pages=mistral_pages, - model=model, - usage_info=usage_info, - object="ocr", - ) - - except Exception as e: - verbose_logger.error(f"Error parsing Azure Document Intelligence response (async): {e}") - raise e + verbose_logger.debug("Azure DI returned 202 Accepted, polling operation (async)...") + operation_url, poll_headers = self._get_polling_target(raw_response) + completed_response = await self._poll_operation_async( + operation_url=operation_url, + headers=poll_headers, + timeout_secs=AZURE_OPERATION_POLLING_TIMEOUT, + ) + return self._transform_completed_response(model=model, raw_response=completed_response) diff --git a/litellm/llms/base_llm/guardrail_translation/base_translation.py b/litellm/llms/base_llm/guardrail_translation/base_translation.py index 68db36b529e..bed06832386 100644 --- a/litellm/llms/base_llm/guardrail_translation/base_translation.py +++ b/litellm/llms/base_llm/guardrail_translation/base_translation.py @@ -1,17 +1,41 @@ from abc import ABC, abstractmethod +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: - from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + ModifyResponseException, + ) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import UserAPIKeyAuth from litellm.types.llms.openai import AllMessageValues +@dataclass(slots=True) +class StreamTransformSink: + """Out-parameter used by ``process_output_streaming_response`` to hand the + guardrailed streaming state back to the caller. + + The streaming text-transform path must not mutate ``responses_so_far`` (it is + the raw accumulator the guardrail re-reads every round), so the guardrailed + accumulated text per choice (``mutated_text_per_choice``, keyed by + ``StreamingChoices.index``) and the per-choice trailing holdback the guardrail + requested (``holdback_per_choice``, from ``stream_holdback_chars``) are + reported here instead of in place. Only the OpenAI chat handler populates this + today; the hook passes a fresh sink per round and reads it afterwards. A + mutable dataclass is deliberate: it is a write-once output parameter for a + single call, not shared state. + """ + + mutated_text_per_choice: dict[int, str] = field(default_factory=dict) + holdback_per_choice: dict[int, int] = field(default_factory=dict) + + class BaseTranslation(ABC): @staticmethod def transform_user_api_key_dict_to_metadata( - user_api_key_dict: Optional[Any], + user_api_key_dict: Any | None, ) -> Dict[str, Any]: """ Transform user_api_key_dict to a metadata dict with prefixed keys. @@ -70,7 +94,7 @@ class BaseTranslation(ABC): guardrail_to_apply: "CustomGuardrail", litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, user_api_key_dict: Optional["UserAPIKeyAuth"] = None, - request_data: Optional[dict] = None, + request_data: dict | None = None, ) -> Any: """ Process output response with guardrails. @@ -89,16 +113,43 @@ class BaseTranslation(ABC): guardrail_to_apply: "CustomGuardrail", litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, user_api_key_dict: Optional["UserAPIKeyAuth"] = None, - request_data: Optional[dict] = None, + request_data: dict | None = None, + stream_transform_sink: StreamTransformSink | None = None, ) -> Any: """ Process output streaming response with guardrails. - Optional to override in subclasses. + Optional to override in subclasses. ``stream_transform_sink`` is the + out-parameter used by handlers that support streaming text + transformations (see ``StreamTransformSink``); base handlers ignore it. """ return responses_so_far - def get_structured_messages(self, data: dict) -> Optional[List["AllMessageValues"]]: + def build_block_sse_chunks( + self, + exc: "ModifyResponseException", + stream_started: bool = False, + responses_so_far: list[Any] | None = None, + ) -> list[bytes] | None: + """ + Build the streaming chunks that deliver a guardrail block message and + cleanly terminate the stream in this provider's wire format. + + ``stream_started`` is True when real chunks were already sent to the + client: the result must *continue* the in-progress message (e.g. close + the open content block and append the block message) rather than start + a new one, which clients reject. ``responses_so_far`` provides the prior + chunks needed to do so. When False, nothing has been sent and a + standalone block message is emitted. + + Returns None when the format has no safe terminator; the caller then + re-raises ``exc`` so the proxy can surface a clean error instead. + Override in provider subclasses that support synthesizing a block + stream. + """ + return None + + def get_structured_messages(self, data: dict) -> List["AllMessageValues"] | None: """ Convert request data to OpenAI-spec structured messages. diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 97ece6b5eab..8a06dd4ea52 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -1,10 +1,100 @@ from __future__ import annotations -from typing import Any, List +import json +from typing import Any, List, Optional +from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage from litellm.types.llms.openai import AllMessageValues +def _anthropic_stream_chunk_events(item: Any) -> list[dict]: + if isinstance(item, dict): + return [item] + if isinstance(item, bytes): + chunk = item.decode("utf-8", errors="replace") + elif isinstance(item, str): + chunk = item + else: + return [] + + events: list[dict] = [] + for block in chunk.split("\n\n"): + for line in block.splitlines(): + stripped = line.strip() + if not stripped.startswith("data:"): + continue + payload = stripped[len("data:") :].strip() + if not payload or payload == "[DONE]": + continue + try: + parsed = json.loads(payload) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + events.append(parsed) + return events + + +def _usage_from_anthropic_stream_chunks(original_response: list[Any]) -> Optional[AnthropicUsage]: + input_tokens = 0 + output_tokens = 0 + found_usage = False + + for item in original_response: + for event in _anthropic_stream_chunk_events(item): + event_type = event.get("type") + if event_type == "message_start": + message = event.get("message") or {} + usage_obj = message.get("usage") or {} + elif event_type == "message_delta": + usage_obj = event.get("usage") or {} + else: + usage_obj = {} + if not isinstance(usage_obj, dict): + continue + if usage_obj.get("input_tokens") is not None: + input_tokens = int(usage_obj.get("input_tokens") or 0) + found_usage = True + if usage_obj.get("output_tokens") is not None: + output_tokens = int(usage_obj.get("output_tokens") or 0) + found_usage = True + + if not found_usage: + return None + return AnthropicUsage(input_tokens=input_tokens, output_tokens=output_tokens) + + +def blocked_response_usage(original_response: Optional[Any]) -> AnthropicUsage: + """ + Token usage for a synthetic guardrail-blocked response. + + A post-call block replaces the LLM's response with the violation message, + but the upstream call already consumed tokens -- report that real usage + (carried on ``ModifyResponseException.original_response``) rather than + discarding it. Pre-call blocks never invoked the LLM (no original_response), + so usage is zero. + """ + usage_obj: Any = None + if isinstance(original_response, list): + stream_usage = _usage_from_anthropic_stream_chunks(original_response) + if stream_usage is not None: + return stream_usage + elif isinstance(original_response, dict): + usage_obj = original_response.get("usage") + elif original_response is not None: + usage_obj = getattr(original_response, "usage", None) + + def _tokens(key: str, fallback_key: str) -> int: + if isinstance(usage_obj, dict): + return int(usage_obj.get(key, usage_obj.get(fallback_key, 0)) or 0) + return int(getattr(usage_obj, key, getattr(usage_obj, fallback_key, 0)) or 0) + + return AnthropicUsage( + input_tokens=_tokens("input_tokens", "prompt_tokens"), + output_tokens=_tokens("output_tokens", "completion_tokens"), + ) + + def effective_skip_system_message_for_guardrail(guardrail_to_apply: Any) -> bool: per = getattr(guardrail_to_apply, "skip_system_message_in_guardrail", None) if per is not None: diff --git a/litellm/llms/base_llm/ocr/transformation.py b/litellm/llms/base_llm/ocr/transformation.py index a38e5bfdcd6..0d878bd308c 100644 --- a/litellm/llms/base_llm/ocr/transformation.py +++ b/litellm/llms/base_llm/ocr/transformation.py @@ -70,6 +70,9 @@ class OCRResponse(LiteLLMPydanticObjectBase): model: str document_annotation: Any | None = None usage_info: OCRUsageInfo | None = None + content: str | None = None + tables: list[dict[str, object]] | None = None + keyValuePairs: list[dict[str, object]] | None = None object: str = "ocr" model_config = {"extra": "allow"} diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py index 380cc91ed98..df811f8d262 100644 --- a/litellm/llms/bedrock/base_aws_llm.py +++ b/litellm/llms/bedrock/base_aws_llm.py @@ -50,6 +50,8 @@ _STS_REGION_FROM_ENDPOINT_PATTERN = re.compile( r"(?:^|\.)sts(?:-fips)?\.([a-z0-9-]+)\.(?:amazonaws\.com(?:\.cn)?|vpce\.amazonaws\.com)" ) +SIGV4_COMPUTED_HEADERS = frozenset({"authorization", "x-amz-date", "x-amz-security-token", "date"}) + class Boto3CredentialsInfo(BaseModel): credentials: Credentials @@ -875,6 +877,15 @@ class BaseAWSLLM: "Resource": "*", "Condition": {"Bool": {"aws:SecureTransport": "true"}}, }, + { + "Sid": "BedrockMantleLiteLLM", + "Effect": "Allow", + "Action": [ + "bedrock-mantle:CreateInference", + ], + "Resource": "*", + "Condition": {"Bool": {"aws:SecureTransport": "true"}}, + }, ], } assume_role_params = { @@ -1400,11 +1411,13 @@ class BaseAWSLLM: # Add back all original headers (including forwarded ones) after signature calculation for header_name, header_value in headers.items(): - if header_value is not None: + if header_value is not None and header_name.lower() not in SIGV4_COMPUTED_HEADERS: request.headers[header_name] = header_value if ( - extra_headers is not None and "Authorization" in extra_headers + extra_headers is not None + and "Authorization" in extra_headers + and not extra_headers["Authorization"].startswith("AWS4-HMAC-SHA256") ): # prevent sigv4 from overwriting the auth header request.headers["Authorization"] = extra_headers["Authorization"] prepped = request.prepare() @@ -1527,9 +1540,15 @@ class BaseAWSLLM: # Add back original headers after signing. Only headers in SignedHeaders # are integrity-protected; forwarded headers (x-forwarded-*) must remain unsigned. for header_name, header_value in headers.items(): - if header_value is not None: + if header_value is not None and header_name.lower() not in SIGV4_COMPUTED_HEADERS: request_headers_dict[header_name] = header_value - if headers is not None and "Authorization" in headers: # prevent sigv4 from overwriting the auth header - request_headers_dict["Authorization"] = headers["Authorization"] + incoming_authorization = next( + (value for name, value in headers.items() if name.lower() == "authorization" and value is not None), + None, + ) + if incoming_authorization is not None and not incoming_authorization.startswith( + "AWS4-HMAC-SHA256" + ): # prevent sigv4 from overwriting the auth header + request_headers_dict["Authorization"] = incoming_authorization return request_headers_dict, request.body diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py index b0e28b6ba90..4fcf7cf91cb 100644 --- a/litellm/llms/bedrock/batches/transformation.py +++ b/litellm/llms/bedrock/batches/transformation.py @@ -5,6 +5,9 @@ from typing import Any, Dict, List, Literal, Optional, Union, cast from httpx import Headers, Response +from litellm.litellm_core_utils.cloud_storage_security import ( + BEDROCK_MANAGED_S3_BATCH_PREFIX, +) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -26,6 +29,15 @@ from litellm.types.utils import LiteLLMBatch, LlmProviders from ..base_aws_llm import BaseAWSLLM from ..common_utils import CommonBatchFilesUtils +# Bedrock batch input files are uploaded as +# s3://bucket/litellm-bedrock-files-{model, ":" -> "-"}-{uuid4}.jsonl (see +# BedrockFilesTransformation._get_s3_object_name). A uuid4 is always 36 hex/dash +# characters, so it can be stripped off the end unambiguously even though the +# model name itself may contain dashes. +_S3_BATCH_FILE_UUID_SUFFIX_PATTERN = re.compile( + r"-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.jsonl$" +) + class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): """ @@ -40,6 +52,41 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.BEDROCK + @classmethod + def _get_bare_model_name_from_s3_key(cls, object_key: str) -> Optional[str]: + if not object_key.startswith(BEDROCK_MANAGED_S3_BATCH_PREFIX): + return None + model_part = object_key[len(BEDROCK_MANAGED_S3_BATCH_PREFIX) :] + match = _S3_BATCH_FILE_UUID_SUFFIX_PATTERN.search(model_part) + if not match or match.start() == 0: + return None + return model_part[: match.start()] + + @classmethod + def is_unmanaged_s3_batch_input_file_id(cls, input_file_id: Optional[str]) -> bool: + """ + Returns True if `input_file_id` is a raw s3:// Bedrock batch input file (i.e. not a + LiteLLM-managed unified file id) whose object key embeds the model name in the + `litellm-bedrock-files-{model}-{uuid}.jsonl` layout. + """ + if input_file_id is None or not input_file_id.startswith("s3://"): + return False + object_key = input_file_id.rsplit("/", 1)[-1] + return cls._get_bare_model_name_from_s3_key(object_key) is not None + + @classmethod + def get_bare_model_name_from_s3_file(cls, input_file_id: str) -> str: + """ + Extracts the bare model name (e.g. "us.anthropic.claude-sonnet-4-20250514-v1-0") from + an unmanaged batch's s3:// input file id. Note any ":" in the original model id was + replaced with "-" at upload time, so callers must fuzzy-match against configured + deployments rather than expect an exact string match. + """ + object_key = input_file_id.rsplit("/", 1)[-1] + bare_model_name = cls._get_bare_model_name_from_s3_key(object_key) + assert bare_model_name is not None # narrowed by is_unmanaged_s3_batch_input_file_id + return bare_model_name + def validate_environment( self, headers: dict, diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index b135a116753..c38b3593465 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -33,6 +33,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( make_valid_bedrock_tool_name, ) from litellm.llms.anthropic.chat.transformation import ( + DROP_UNSUPPORTED_ADAPTIVE_THINKING_WARNING, DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING, REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT, AnthropicConfig, @@ -76,6 +77,7 @@ from litellm.utils import ( from ..common_utils import ( BedrockError, BedrockModelInfo, + bedrock_converse_supports_parallel_tool_use_config, get_anthropic_beta_from_headers, get_bedrock_tool_name, is_claude_4_5_on_bedrock, @@ -422,6 +424,7 @@ class AmazonConverseConfig(BaseConfig): mapped_thinking = AnthropicConfig._map_reasoning_effort( reasoning_effort=reasoning_effort, model=model, + custom_llm_provider="bedrock", llm_provider="bedrock_converse", ) if mapped_thinking is None: @@ -429,7 +432,7 @@ class AmazonConverseConfig(BaseConfig): optional_params.pop("output_config", None) else: optional_params["thinking"] = mapped_thinking - if AnthropicConfig._is_adaptive_thinking_model(model): + if AnthropicConfig._is_adaptive_thinking_model(model, "bedrock"): mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(reasoning_effort) if mapped_effort is None: AnthropicConfig._raise_invalid_reasoning_effort( @@ -464,7 +467,7 @@ class AmazonConverseConfig(BaseConfig): model=model, llm_provider="bedrock_converse", ) - error = AnthropicConfig._validate_effort_for_model(model=model, effort=effort) + error = AnthropicConfig._validate_effort_for_model(model=model, effort=effort, custom_llm_provider="bedrock") if error is not None: raise litellm.exceptions.BadRequestError( message=error, @@ -897,7 +900,28 @@ class AmazonConverseConfig(BaseConfig): "tool_choice": {"disable_parallel_tool_use": disable_parallel} } if param == "thinking": - optional_params["thinking"] = value + if ( + isinstance(value, dict) + and value.get("type") == "adaptive" + and not AnthropicConfig._is_adaptive_thinking_model(model, "bedrock") + ): + max_tokens = non_default_params.get("max_completion_tokens") or non_default_params.get("max_tokens") + legacy_thinking = AnthropicConfig._map_reasoning_effort( + reasoning_effort="medium", + model=model, + custom_llm_provider="bedrock", + ) + capped = ( + AnthropicConfig._cap_thinking_budget_to_max_tokens(legacy_thinking, max_tokens) + if legacy_thinking is not None + else None + ) + if capped is not None: + optional_params["thinking"] = capped + else: + litellm.verbose_logger.warning(DROP_UNSUPPORTED_ADAPTIVE_THINKING_WARNING, model) + else: + optional_params["thinking"] = value elif param == "reasoning_effort" and isinstance(value, str): self._handle_reasoning_effort_parameter( model=model, reasoning_effort=value, optional_params=optional_params @@ -1106,18 +1130,28 @@ class AmazonConverseConfig(BaseConfig): if cache_control is None: return None - cache_point = CachePointBlock(type="default") - if isinstance(cache_control, dict) and "ttl" in cache_control: - ttl = cache_control["ttl"] - if ttl in ["5m", "1h"] and model is not None: - if is_claude_4_5_on_bedrock(model): - cache_point["ttl"] = ttl + cache_point = self._build_cache_point_block(cache_control, model) if block_type == "system": return SystemContentBlock(cachePoint=cache_point) else: return ContentBlock(cachePoint=cache_point) + @staticmethod + def _build_cache_point_block(control: Optional[dict], model: Optional[str] = None) -> CachePointBlock: + """Build a Bedrock ``cachePoint`` block from an OpenAI-style ``cache_control``/``control`` dict. + + ``type`` is always ``"default"`` (the only value Bedrock's Converse API + accepts). ``ttl`` is only honored for models that support extended TTL + caching (Claude 4.5 family on Bedrock). + """ + cache_point = CachePointBlock(type="default") + if isinstance(control, dict) and "ttl" in control: + ttl = control["ttl"] + if ttl in ["5m", "1h"] and model is not None and is_claude_4_5_on_bedrock(model): + cache_point["ttl"] = ttl + return cache_point + def _transform_system_message( self, messages: List[AllMessageValues], model: Optional[str] = None ) -> Tuple[List[AllMessageValues], List[SystemContentBlock]]: @@ -1241,7 +1275,7 @@ class AmazonConverseConfig(BaseConfig): # Handle parallel_tool_calls configuration parallel_tool_use_config = additional_request_params.pop("_parallel_tool_use_config", None) - if parallel_tool_use_config is not None and is_claude_4_5_on_bedrock(model): + if parallel_tool_use_config is not None and bedrock_converse_supports_parallel_tool_use_config(model): for key, value in parallel_tool_use_config.items(): if ( key in additional_request_params @@ -1268,7 +1302,7 @@ class AmazonConverseConfig(BaseConfig): if anthropic_output_config is not None and isinstance(anthropic_output_config, dict): if base_model.startswith("anthropic"): - if litellm.drop_params is True and not AnthropicConfig._model_supports_effort_param(model): + if litellm.drop_params is True and not AnthropicConfig._model_supports_effort_param(model, "bedrock"): litellm.verbose_logger.warning( DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING, model, @@ -1411,7 +1445,7 @@ class AmazonConverseConfig(BaseConfig): if ( isinstance(output_config, dict) and output_config.get("effort") is not None - and not AnthropicConfig._is_adaptive_thinking_model(model) + and not AnthropicConfig._is_adaptive_thinking_model(model, "bedrock") ): from litellm.types.llms.anthropic import ( ANTHROPIC_EFFORT_BETA_HEADER, @@ -1526,7 +1560,8 @@ class AmazonConverseConfig(BaseConfig): if cache_injection_points and len(bedrock_tools) > 0: for point in cache_injection_points: if point.get("location") == "tool_config": - bedrock_tools.append({"cachePoint": {"type": "default"}}) + cache_point = self._build_cache_point_block(point.get("control"), model) + bedrock_tools.append(ToolBlock(cachePoint=cache_point)) break bedrock_tool_config: Optional[ToolConfigBlock] = None diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py index b381b5a85fe..4c256be1ab8 100644 --- a/litellm/llms/bedrock/chat/invoke_handler.py +++ b/litellm/llms/bedrock/chat/invoke_handler.py @@ -1558,7 +1558,7 @@ class AWSEventStreamDecoder: text = chunk_data["outputText"] # ai21 mapping elif "ai21" in self.model: # fake ai21 streaming - text = chunk_data.get("completions")[0].get("data").get("text") # type: ignore + text = chunk_data["completions"][0]["data"]["text"] is_finished = True finish_reason = "stop" ######## /bedrock/converse mappings ############### diff --git a/litellm/llms/bedrock/chat/invoke_transformations/amazon_qwen2_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/amazon_qwen2_transformation.py index d63642c806f..a2aa98d6676 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/amazon_qwen2_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/amazon_qwen2_transformation.py @@ -51,10 +51,7 @@ class AmazonQwen2Config(AmazonQwen3Config): Qwen2 uses "text" field, but we also support "generation" field for compatibility. """ try: - if hasattr(raw_response, "json"): - response_data = raw_response.json() - else: - response_data = raw_response + response_data = raw_response.json() # Extract the generated text - Qwen2 uses "text" field, but also support "generation" for compatibility generated_text = response_data.get("generation", "") or response_data.get("text", "") diff --git a/litellm/llms/bedrock/chat/invoke_transformations/amazon_qwen3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/amazon_qwen3_transformation.py index 762631cac5e..4f496df084e 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/amazon_qwen3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/amazon_qwen3_transformation.py @@ -175,10 +175,7 @@ class AmazonQwen3Config(AmazonInvokeConfig, BaseConfig): Transform Qwen3 Bedrock response to OpenAI format """ try: - if hasattr(raw_response, "json"): - response_data = raw_response.json() - else: - response_data = raw_response + response_data = raw_response.json() # Extract the generated text - Qwen3 uses "generation" field generated_text = response_data.get("generation", "") diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py index 60d532eb8c5..6b5cb304bec 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -115,7 +115,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): keeps working. Non-adaptive models and models without a ceiling are left untouched. """ - if not AnthropicConfig._is_adaptive_thinking_model(model): + if not AnthropicConfig._is_adaptive_thinking_model(model, "bedrock"): return effort = params.get("reasoning_effort") if not isinstance(effort, str): @@ -228,7 +228,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): custom_llm_provider="bedrock", key="supports_output_config", ) - or AnthropicConfig._model_supports_effort_param(model) + or AnthropicConfig._model_supports_effort_param(model, "bedrock") ): if anthropic_request.pop("output_config", None) is not None: verbose_logger.warning( @@ -269,6 +269,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): prompt_caching_set=False, file_id_used=self.is_file_id_used(messages), mcp_server_used=self.is_mcp_server_used(optional_params.get("mcp_servers")), + custom_llm_provider="bedrock", ) beta_set.update(auto_betas) diff --git a/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py index bbe16e26713..dd7cf12604d 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py @@ -5,6 +5,7 @@ from functools import partial from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union, cast, get_args import httpx +from pydantic import TypeAdapter, ValidationError import litellm from litellm._logging import verbose_logger @@ -24,6 +25,7 @@ from litellm.llms.custom_httpx.http_handler import ( HTTPHandler, _get_httpx_client, ) +from litellm.types.llms.bedrock import GuardrailConfigBlock from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import ModelResponse, Usage from litellm.utils import CustomStreamWrapper @@ -37,6 +39,38 @@ else: from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM +_GUARDRAIL_CONFIG_VALIDATOR: "TypeAdapter[GuardrailConfigBlock]" = TypeAdapter(GuardrailConfigBlock) + +_GUARDRAIL_CONFIG_EXPECTED_FORMAT = ( + "{'guardrailIdentifier': str, 'guardrailVersion': str, 'trace': 'enabled'|'disabled'|'enabled_full'}" +) + + +def _bedrock_invoke_guardrail_headers(raw_guardrail_config: object) -> "dict[str, str]": + try: + guardrail_config = _GUARDRAIL_CONFIG_VALIDATOR.validate_python(raw_guardrail_config) + except ValidationError as e: + raise BedrockError( + status_code=400, + message="Invalid guardrailConfig={}. Expected format: {}. Error: {}".format( + raw_guardrail_config, _GUARDRAIL_CONFIG_EXPECTED_FORMAT, e + ), + ) + if "guardrailIdentifier" not in guardrail_config: + raise BedrockError( + status_code=400, + message="guardrailConfig={} is missing 'guardrailIdentifier'. Expected format: {}".format( + raw_guardrail_config, _GUARDRAIL_CONFIG_EXPECTED_FORMAT + ), + ) + trace = guardrail_config.get("trace") + candidate_headers = { + "X-Amzn-Bedrock-GuardrailIdentifier": guardrail_config.get("guardrailIdentifier"), + "X-Amzn-Bedrock-GuardrailVersion": guardrail_config.get("guardrailVersion"), + "X-Amzn-Bedrock-Trace": trace.upper() if trace is not None else None, + } + return {name: value for name, value in candidate_headers.items() if value is not None} + class AmazonInvokeConfig(BaseConfig, BaseAWSLLM): def __init__(self, **kwargs): @@ -390,7 +424,16 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM): api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: - return headers + raw_guardrail_config = optional_params.pop("guardrailConfig", None) + if raw_guardrail_config is None: + return headers + existing_header_names = frozenset(name.lower() for name in headers) + guardrail_headers = { + name: value + for name, value in _bedrock_invoke_guardrail_headers(raw_guardrail_config).items() + if name.lower() not in existing_header_names + } + return {**headers, **guardrail_headers} def get_error_class( self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] diff --git a/litellm/llms/bedrock/chat/mantle/transformation.py b/litellm/llms/bedrock/chat/mantle/transformation.py index d84e077c37b..d7ffff65ff0 100644 --- a/litellm/llms/bedrock/chat/mantle/transformation.py +++ b/litellm/llms/bedrock/chat/mantle/transformation.py @@ -7,13 +7,14 @@ 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 typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeConfig, ) from litellm.llms.bedrock.common_utils import build_mantle_messages_url from litellm.types.llms.openai import AllMessageValues +from litellm.types.utils import ModelResponse if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -91,10 +92,14 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig): 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 + # The parent strips "model" and "stream" from the body (Invoke API puts + # the model in the URL and streams via a dedicated endpoint). The mantle + # endpoint (Messages API) requires both in the body. + return self._restore_mantle_body_fields( + request=request, + model_id=model_id, + optional_params=optional_params, + ) async def async_transform_request( self, @@ -114,5 +119,31 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig): headers=headers, ) await self._async_convert_document_url_sources_to_base64(request) - request["model"] = model_id - return request + return self._restore_mantle_body_fields( + request=request, + model_id=model_id, + optional_params=optional_params, + ) + + @staticmethod + def _restore_mantle_body_fields(request: dict, model_id: str, optional_params: dict) -> dict: + stream_fields: dict = {"stream": True} if optional_params.get("stream") is True else {} + return {**request, "model": model_id, **stream_fields} + + @property + def has_custom_stream_wrapper(self) -> bool: + return False + + def get_model_response_iterator( + self, + streaming_response: Iterator[str] | AsyncIterator[str] | ModelResponse, + sync_stream: bool, + json_mode: Optional[bool] = False, + ) -> Any: + from litellm.llms.anthropic.chat.handler import ModelResponseIterator + + return ModelResponseIterator( + streaming_response=streaming_response, + sync_stream=sync_stream, + json_mode=json_mode, + ) diff --git a/litellm/llms/bedrock/claude_platform/transformation.py b/litellm/llms/bedrock/claude_platform/transformation.py index 0868d9bddfe..6f5ccececc7 100644 --- a/litellm/llms/bedrock/claude_platform/transformation.py +++ b/litellm/llms/bedrock/claude_platform/transformation.py @@ -54,7 +54,9 @@ class BedrockClaudePlatformConfig(BedrockClaudePlatformMixin, AnthropicConfig): tool_search_used=self.is_tool_search_used(tools=optional_params.get("tools")), programmatic_tool_calling_used=self.is_programmatic_tool_calling_used(tools=optional_params.get("tools")), input_examples_used=self.is_input_examples_used(tools=optional_params.get("tools")), - effort_used=self.is_effort_used(optional_params=optional_params, model=model), + effort_used=self.is_effort_used( + optional_params=optional_params, model=model, custom_llm_provider="anthropic" + ), user_anthropic_beta_headers=self._get_user_anthropic_beta_headers( anthropic_beta_header=headers.get("anthropic-beta") ), diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index df432a4d7e3..5114677ffc0 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -685,39 +685,27 @@ def get_bedrock_base_model(model: str) -> str: return model +def bedrock_converse_supports_parallel_tool_use_config(model: str) -> bool: + return any( + (litellm.model_cost.get(candidate) or {}).get("supports_parallel_tool_use_config") is True + for candidate in (model, get_bedrock_base_model(model)) + ) + + def is_claude_4_5_on_bedrock(model: str) -> bool: """ - Check if the model is a Claude 4.5 model on Bedrock. - Claude 4.5 models support prompt caching with '5m' and '1h' TTL on Bedrock. + Check if the model supports Bedrock prompt caching with an extended '1h' TTL + (in addition to the default 5m TTL). + + Backed by the ``cache_creation_input_token_cost_above_1hr`` field in + ``model_prices_and_context_window.json`` instead of a hardcoded list of + model-name patterns, so newly released models pick up support as soon as + their pricing entry ships, with no code change required here. """ - model_lower = model.lower() - claude_4_5_patterns = [ - "sonnet-4.5", - "sonnet_4.5", - "sonnet-4-5", - "sonnet_4_5", - "haiku-4.5", - "haiku_4.5", - "haiku-4-5", - "haiku_4_5", - "opus-4.5", - "opus_4.5", - "opus-4-5", - "opus_4_5", - "sonnet-4.6", - "sonnet_4.6", - "sonnet-4-6", - "sonnet_4_6", - "opus-4.6", - "opus_4.6", - "opus-4-6", - "opus_4_6", - "opus-4.7", - "opus_4.7", - "opus-4-7", - "opus_4_7", - ] - return any(pattern in model_lower for pattern in claude_4_5_patterns) + return any( + (litellm.model_cost.get(candidate) or {}).get("cache_creation_input_token_cost_above_1hr") is not None + for candidate in (model, get_bedrock_base_model(model)) + ) _BEDROCK_MODEL_VERSION_SUFFIX_RE = re.compile(r"-v\d+(?::\d+)?$") 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 f5309d521a9..a00d3ba1363 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -77,6 +77,10 @@ class AmazonAnthropicClaudeMessagesConfig( DEFAULT_BEDROCK_ANTHROPIC_API_VERSION = "bedrock-2023-05-31" + @property + def custom_llm_provider(self) -> Optional[str]: + return "bedrock" + BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS = frozenset(BedrockInvokeAnthropicMessagesRequest.__annotations__.keys()) def __init__(self, **kwargs): @@ -93,26 +97,48 @@ class AmazonAnthropicClaudeMessagesConfig( return [{"type": "text", "text": value}] return [value] - def _normalize_system_role_messages_for_bedrock(self, anthropic_messages_request: dict) -> None: - """Bedrock Invoke rejects ``role: "system"`` entries inside ``messages`` on - some Claude aliases; Anthropic Messages carries that content in the - top-level ``system`` field. Move any such entries into ``system`` before - the Invoke request is built.""" + @staticmethod + def _is_system_role_message(message: Any) -> bool: + return isinstance(message, dict) and message.get("role") == "system" + + def _normalize_system_role_messages_for_bedrock(self, anthropic_messages_request: dict, model: str) -> None: + """Bedrock Invoke validates ``role: "system"`` entries inside ``messages`` + per model. Models carrying ``supports_mid_conversation_system`` in the + cost map (the Opus 4.8 family) only reject a leading run ("messages.0: + use the top-level 'system' parameter for the initial system prompt") and + accept mid-conversation entries (e.g. Claude Code's + ``mid-conversation-system-2026-04-07`` reminders) in place, where they + MUST stay: hoisting one mutates the ``system`` prefix and invalidates the + prompt cache for the entire message history. Older Claude models (Opus + 4.7, Sonnet 4.6, Haiku 4.5, ...) reject the role in every position + ("role 'system' is not supported on this model"), so without the flag + every system entry is hoisted into the top-level ``system`` field. + Billing-header system blocks are stripped from the top-level ``system`` + field regardless of whether anything was hoisted.""" messages = anthropic_messages_request.get("messages") if not isinstance(messages, list): return - system_role_messages = [m for m in messages if isinstance(m, dict) and m.get("role") == "system"] - if not system_role_messages: - return - - anthropic_messages_request["messages"] = [ - m for m in messages if not (isinstance(m, dict) and m.get("role") == "system") - ] + if _supports_factory( + model=model, + custom_llm_provider="bedrock", + key="supports_mid_conversation_system", + ): + leading_count = next( + (i for i, m in enumerate(messages) if not self._is_system_role_message(m)), + len(messages), + ) + hoisted = messages[:leading_count] + remaining = messages[leading_count:] + else: + hoisted = [m for m in messages if self._is_system_role_message(m)] + remaining = [m for m in messages if not self._is_system_role_message(m)] + if hoisted: + anthropic_messages_request["messages"] = remaining system_content = [ block for source in ( anthropic_messages_request.get("system"), - *(m.get("content") for m in system_role_messages), + *(m.get("content") for m in hoisted), ) for block in self._as_system_content_blocks(source) ] @@ -247,7 +273,7 @@ class AmazonAnthropicClaudeMessagesConfig( Returns: True if the model supports extended thinking on Bedrock """ - if AnthropicModelInfo._is_adaptive_thinking_model(model): + if AnthropicModelInfo._is_adaptive_thinking_model(model, "bedrock"): return True model_lower = model.lower() @@ -297,7 +323,7 @@ class AmazonAnthropicClaudeMessagesConfig( if not self._supports_extended_thinking_on_bedrock(model): return False - is_adaptive_thinking_model = AnthropicModelInfo._is_adaptive_thinking_model(model) + is_adaptive_thinking_model = AnthropicModelInfo._is_adaptive_thinking_model(model, "bedrock") thinking = anthropic_messages_request.get("thinking") if isinstance(thinking, dict): @@ -489,24 +515,43 @@ class AmazonAnthropicClaudeMessagesConfig( if self._supports_tool_search_on_bedrock(model): beta_set.add("tool-search-tool-2025-10-19") + # Bedrock-InvokeModel-supported ``context_management.edits`` types and the + # ``anthropic-beta`` header that each one requires. ``clear_thinking_20251015`` + # is intentionally absent — it is LiteLLM-internal, consumed via + # ``_ensure_thinking_for_clear_thinking_context_management``, and forwarding + # the raw edit trips Bedrock's + # ``"context_management: Extra inputs are not permitted"`` 400. + # + # Bedrock InvokeModel DOES support ``clear_tool_uses_20250919`` under the + # ``context-management-2025-06-27`` beta. AWS docs: + # https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-tool-use.md + _BEDROCK_INVOKE_SUPPORTED_CONTEXT_MANAGEMENT_EDITS: Dict[str, str] = { + "compact_20260112": ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_01_12.value, + "clear_tool_uses_20250919": ANTHROPIC_BETA_HEADER_VALUES.CONTEXT_MANAGEMENT_2025_06_27.value, + } + @staticmethod def _filter_context_management_for_bedrock_invoke( anthropic_messages_request: Dict, beta_set: set, ) -> None: """ - Bedrock InvokeModel accepts ``context_management`` only when it carries - ``compact_20260112`` edits paired with the ``compact-2026-01-12`` - anthropic-beta header. Other edit types (notably ``clear_thinking_20251015``, - which Claude Code sends on every request) are LiteLLM-internal and would - cause Bedrock to 400 with ``"context_management: Extra inputs are not - permitted"``. + Filter ``context_management.edits`` to the subset that Bedrock InvokeModel + accepts and add the matching ``anthropic-beta`` header for each surviving + edit type. - Filter the edits list to the supported subset, add the beta header when - compact edits remain, and drop ``context_management`` entirely when no - supported edits are left so the safety-net allowlist can pass it through. + - ``compact_20260112`` -> ``compact-2026-01-12`` + - ``clear_tool_uses_20250919`` -> ``context-management-2025-06-27`` - Ref: https://github.com/BerriAI/litellm/issues/27532 + Other edit types (notably ``clear_thinking_20251015``, which Claude Code + sends on every request) are LiteLLM-internal: thinking is injected + separately via ``_ensure_thinking_for_clear_thinking_context_management``, + and forwarding the raw edit would trip Bedrock's + ``"context_management: Extra inputs are not permitted"`` 400. + + Refs: + * https://github.com/BerriAI/litellm/issues/27532 + * https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-tool-use.md """ cm = anthropic_messages_request.get("context_management") if not isinstance(cm, dict): @@ -516,15 +561,17 @@ class AmazonAnthropicClaudeMessagesConfig( anthropic_messages_request.pop("context_management", None) return - compact_edits = [e for e in edits if isinstance(e, dict) and e.get("type") == "compact_20260112"] - if compact_edits: - beta_set.add(ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_01_12.value) - anthropic_messages_request["context_management"] = { - **cm, - "edits": compact_edits, - } - else: + supported = AmazonAnthropicClaudeMessagesConfig._BEDROCK_INVOKE_SUPPORTED_CONTEXT_MANAGEMENT_EDITS + retained_edits = [e for e in edits if isinstance(e, dict) and e.get("type") in supported] + if not retained_edits: anthropic_messages_request.pop("context_management", None) + return + + beta_set.update(supported[e["type"]] for e in retained_edits) + anthropic_messages_request["context_management"] = { + **cm, + "edits": retained_edits, + } def _get_bedrock_invoke_anthropic_beta_headers( self, @@ -553,6 +600,7 @@ class AmazonAnthropicClaudeMessagesConfig( mcp_server_used=anthropic_model_info.is_mcp_server_used( anthropic_messages_optional_request_params.get("mcp_servers") ), + custom_llm_provider="bedrock", ) beta_set.update(auto_betas) @@ -619,7 +667,7 @@ class AmazonAnthropicClaudeMessagesConfig( path degrades ``xhigh`` -> ``max`` rather than 400-ing. Non-adaptive models and models without a ceiling are left untouched. """ - if not AnthropicModelInfo._is_adaptive_thinking_model(model): + if not AnthropicModelInfo._is_adaptive_thinking_model(model, "bedrock"): return effort = optional_params.get("reasoning_effort") if not isinstance(effort, str): @@ -648,7 +696,7 @@ class AmazonAnthropicClaudeMessagesConfig( litellm_params=litellm_params, headers=headers, ) - self._normalize_system_role_messages_for_bedrock(anthropic_messages_request) + self._normalize_system_role_messages_for_bedrock(anthropic_messages_request, model=model) ######################################################### ############## BEDROCK Invoke SPECIFIC TRANSFORMATION ### ######################################################### @@ -707,7 +755,7 @@ class AmazonAnthropicClaudeMessagesConfig( custom_llm_provider="bedrock", key="supports_output_config", ) - or AnthropicConfig._model_supports_effort_param(model) + or AnthropicConfig._model_supports_effort_param(model, "bedrock") ): if anthropic_messages_request.pop("output_config", None) is not None: verbose_logger.warning( @@ -744,7 +792,7 @@ class AmazonAnthropicClaudeMessagesConfig( if ( litellm.drop_params is True and "output_config" in anthropic_messages_request - and not AnthropicConfig._model_supports_effort_param(model) + and not AnthropicConfig._model_supports_effort_param(model, "bedrock") ): verbose_logger.warning( DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING, diff --git a/litellm/llms/bedrock/messages/mantle_transformation.py b/litellm/llms/bedrock/messages/mantle_transformation.py index a8a7b7ed1d5..da7b8697a6b 100644 --- a/litellm/llms/bedrock/messages/mantle_transformation.py +++ b/litellm/llms/bedrock/messages/mantle_transformation.py @@ -6,8 +6,13 @@ 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, Tuple +from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Tuple +import httpx + +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) from litellm.llms.bedrock.common_utils import build_mantle_messages_url from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeMessagesConfig, @@ -89,8 +94,26 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig): 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 + # Parent (AmazonAnthropicClaudeMessagesConfig) removes "model" and + # "stream" from the body (Bedrock Invoke puts the model in the URL and + # streams via a dedicated endpoint). The mantle endpoint (Messages API) + # requires both in the request body. + stream_fields: dict[str, bool] = ( + {"stream": True} if anthropic_messages_optional_request_params.get("stream") is True else {} + ) + return {**request, "model": model_id, **stream_fields} + + def get_async_streaming_response_iterator( + self, + model: str, + httpx_response: httpx.Response, + request_body: dict, + litellm_logging_obj: LiteLLMLoggingObj, + ) -> AsyncIterator: + return AnthropicMessagesConfig.get_async_streaming_response_iterator( + self, + model=model, + httpx_response=httpx_response, + request_body=request_body, + litellm_logging_obj=litellm_logging_obj, + ) diff --git a/litellm/llms/bedrock/realtime/handler.py b/litellm/llms/bedrock/realtime/handler.py index 557ee3348d5..b48c37791c4 100644 --- a/litellm/llms/bedrock/realtime/handler.py +++ b/litellm/llms/bedrock/realtime/handler.py @@ -13,6 +13,7 @@ from litellm._logging import _redact_string, verbose_proxy_logger from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from ..base_aws_llm import BaseAWSLLM +from ..common_utils import BedrockError from .transformation import BedrockRealtimeConfig @@ -59,9 +60,7 @@ class BedrockRealtime(BaseAWSLLM): InvokeModelWithBidirectionalStreamOperationInput, ) from aws_sdk_bedrock_runtime.config import Config - from smithy_aws_core.identity.environment import ( - EnvironmentCredentialsResolver, - ) + from smithy_aws_core.identity import StaticCredentialsResolver except ImportError: raise ImportError("Missing aws_sdk_bedrock_runtime. Install with: pip install aws-sdk-bedrock-runtime") @@ -82,11 +81,36 @@ class BedrockRealtime(BaseAWSLLM): verbose_proxy_logger.debug(f"Bedrock Realtime: Connecting to {endpoint_uri} with model {model}") + credentials = self.get_credentials( + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + aws_region_name=aws_region_name, + aws_session_name=aws_session_name, + aws_profile_name=aws_profile_name, + aws_role_name=aws_role_name, + aws_web_identity_token=aws_web_identity_token, + aws_sts_endpoint=aws_sts_endpoint, + aws_external_id=aws_external_id, + ) + if credentials is None: + raise BedrockError( + status_code=401, + message=( + "No AWS credentials found for Bedrock realtime. Set aws_* params in litellm_params " + "or configure credentials in the environment" + ), + ) + frozen_credentials = credentials.get_frozen_credentials() + # Initialize Bedrock client with aws_sdk_bedrock_runtime config = Config( endpoint_uri=endpoint_uri, region=aws_region_name, - aws_credentials_identity_resolver=EnvironmentCredentialsResolver(), + aws_access_key_id=frozen_credentials.access_key, + aws_secret_access_key=frozen_credentials.secret_key, + aws_session_token=frozen_credentials.token, + aws_credentials_identity_resolver=StaticCredentialsResolver(), ) bedrock_client = BedrockRuntimeClient(config=config) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 3c10239f868..96d0ad48b79 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1300,16 +1300,15 @@ class BaseLLMHTTPHandler: if client is None or not isinstance(client, HTTPHandler): client = _get_httpx_client() + json_data = data if files is None and isinstance(data, dict) else None + try: - # Make the POST request - clean and simple, always use data and files response = client.post( url=complete_url, headers=headers, - data=data, + data=data if json_data is None else None, files=files, - json=( - data if files is None and isinstance(data, dict) else None - ), # Use json param only when no files and data is dict + json=json_data, timeout=timeout, ) except Exception as e: @@ -1373,16 +1372,15 @@ class BaseLLMHTTPHandler: else: async_httpx_client = client + json_data = data if files is None and isinstance(data, dict) else None + try: - # Make the async POST request - clean and simple, always use data and files response = await async_httpx_client.post( url=complete_url, headers=headers, - data=data, + data=data if json_data is None else None, files=files, - json=( - data if files is None and isinstance(data, dict) else None - ), # Use json param only when no files and data is dict + json=json_data, timeout=timeout, ) except Exception as e: @@ -2084,12 +2082,18 @@ class BaseLLMHTTPHandler: initial_response: Union[AsyncIterator, AnthropicMessagesResponse] if stream: + from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import ( + AnthropicMessagesStreamingResponse, + anthropic_messages_stream_hidden_params, + ) + completion_stream = anthropic_messages_provider_config.get_async_streaming_response_iterator( model=model, httpx_response=response, request_body=request_body, litellm_logging_obj=logging_obj, ) + stream_hidden_params = anthropic_messages_stream_hidden_params(response.headers) if not self._has_agentic_completion_hook(logging_obj): # No callback overrides async_should_run_agentic_loop, so the @@ -2097,7 +2101,10 @@ class BaseLLMHTTPHandler: # and rebuilding the response from SSE at end-of-stream to call # hooks that all return (False, {}). Stream through directly and # skip that per-chunk + end-of-stream overhead. - return completion_stream + return AnthropicMessagesStreamingResponse( + completion_stream=completion_stream, + hidden_params=stream_hidden_params, + ) from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import ( AgenticAnthropicStreamingIterator, @@ -2114,7 +2121,10 @@ class BaseLLMHTTPHandler: custom_llm_provider=custom_llm_provider, kwargs={**kwargs, "api_key": api_key} if api_key else kwargs, ) - return initial_response + return AnthropicMessagesStreamingResponse( + completion_stream=initial_response, + hidden_params=stream_hidden_params, + ) else: initial_response = anthropic_messages_provider_config.transform_anthropic_messages_response( model=model, @@ -2647,9 +2657,10 @@ class BaseLLMHTTPHandler: ) result = final_response if final_response is not None else initial_response - if litellm_params.get("_code_interpreter_interception_converted_stream") and not litellm_params.get( - "_agentic_loop_depth" - ): + interception_converted_stream = litellm_params.get( + "_code_interpreter_interception_converted_stream" + ) or litellm_params.get("_websearch_interception_converted_stream") + if interception_converted_stream and not litellm_params.get("_agentic_loop_depth"): return self._wrap_responses_response_as_fake_stream( result=result, model=model, @@ -2902,6 +2913,7 @@ class BaseLLMHTTPHandler: try: response = sync_httpx_client.get(url=url, headers=headers, params=data) + response.raise_for_status() except Exception as e: raise self._handle_error( e=e, @@ -2973,9 +2985,9 @@ class BaseLLMHTTPHandler: try: response = await async_httpx_client.get(url=url, headers=headers, params=data) - + response.raise_for_status() except Exception as e: - verbose_logger.exception(f"Error retrieving response: {e}") + verbose_logger.debug(f"Error retrieving response: {e}") raise self._handle_error( e=e, provider_config=responses_api_provider_config, @@ -3066,6 +3078,7 @@ class BaseLLMHTTPHandler: try: response = sync_httpx_client.get(url=url, headers=headers, params=params) + response.raise_for_status() except Exception as e: raise self._handle_error(e=e, provider_config=responses_api_provider_config) @@ -3139,6 +3152,7 @@ class BaseLLMHTTPHandler: try: response = await async_httpx_client.get(url=url, headers=headers, params=params) + response.raise_for_status() except Exception as e: raise self._handle_error(e=e, provider_config=responses_api_provider_config) @@ -4725,6 +4739,13 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) + if response.status_code >= 400: + raise provider_config.get_error_class( + error_message=response.text, + status_code=response.status_code, + headers=response.headers, + ) + return provider_config.transform_file_content_response( raw_response=response, logging_obj=logging_obj, @@ -4781,6 +4802,13 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) + if response.status_code >= 400: + raise provider_config.get_error_class( + error_message=response.text, + status_code=response.status_code, + headers=response.headers, + ) + return provider_config.transform_file_content_response( raw_response=response, logging_obj=logging_obj, @@ -5197,6 +5225,8 @@ class BaseLLMHTTPHandler: tools = anthropic_messages_optional_request_params.get("tools", []) depth, max_loops, fingerprints = self._get_agentic_loop_settings(kwargs=kwargs) + hook_kwargs = {**kwargs, "_agentic_loop_api_surface": api_surface} + for callback in callbacks: if not isinstance(callback, CustomLogger): continue @@ -5217,7 +5247,7 @@ class BaseLLMHTTPHandler: tools=tools, stream=stream, custom_llm_provider=custom_llm_provider, - kwargs=kwargs, + kwargs=hook_kwargs, ) except Exception as e: _call_id = getattr(logging_obj, "litellm_call_id", "unknown") @@ -5243,7 +5273,7 @@ class BaseLLMHTTPHandler: ) try: - kwargs_with_provider = kwargs.copy() if kwargs else {} + kwargs_with_provider = hook_kwargs.copy() 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 diff --git a/litellm/llms/dashscope/cost_calculator.py b/litellm/llms/dashscope/cost_calculator.py index 2f710d78126..2732b97cd35 100644 --- a/litellm/llms/dashscope/cost_calculator.py +++ b/litellm/llms/dashscope/cost_calculator.py @@ -7,6 +7,7 @@ Handles tiered pricing and prompt caching scenarios. from dataclasses import dataclass from typing import List, Optional, Tuple +from litellm.litellm_core_utils.llm_cost_calc.tiered_pricing import calculate_tiered_cost from litellm.types.utils import ModelInfo, Usage from litellm.utils import get_model_info @@ -42,80 +43,6 @@ def _extract_token_breakdown(usage: Usage) -> TokenBreakdown: return TokenBreakdown(text_tokens, cached_tokens, completion_tokens, reasoning_tokens) -def _calculate_tiered_cost( - tokens: int, - tiered_pricing: List[dict], - cost_key: str, - fallback_cost_key: Optional[str] = None, -) -> float: - """ - Calculate cost for a given number of tokens based on a true tiered pricing structure. - - This function iterates through sorted pricing tiers, calculates the cost for the - number of tokens that fall into each tier's range, and sums them up to get the total cost. - - Args: - tokens (int): The total number of tokens to calculate the cost for. - tiered_pricing (List[dict]): A list of dictionaries, where each dictionary - represents a pricing tier. - cost_key (str): The key in the tier dictionary that holds the per-token cost - (e.g., 'input_cost_per_token'). - fallback_cost_key (Optional[str], optional): A fallback key to use if the - primary `cost_key` is not found in a tier. Defaults to None. - - Returns: - float: The total calculated cost for the given tokens. - - Example: - >>> tiered_pricing = [ - ... {"range": [0, 100000], "input_cost_per_token": 0.0001}, - ... {"range": [100000, 500000], "input_cost_per_token": 0.00005}, - ... ] - - Calculating cost for 150,000 tokens: - (100,000 * 0.0001) + (50,000 * 0.00005) = $12.5 - """ - if not tiered_pricing or tokens <= 0: - return 0.0 - - total_cost = 0.0 - tokens_processed = 0 - - sorted_tiers = sorted(tiered_pricing, key=lambda x: x.get("range", [0, 0])[0]) - - for tier in sorted_tiers: - if tokens_processed >= tokens: - break - - tier_range = tier.get("range", []) - if len(tier_range) != 2: - continue - - range_start, range_end = tier_range - - if tokens <= range_start: - continue - - tier_start = max(range_start, tokens_processed) - tier_end = min(range_end, tokens) - - if tier_end > tier_start: - tokens_in_tier = tier_end - tier_start - cost_per_token = tier.get(cost_key) or tier.get(fallback_cost_key, 0) - total_cost += tokens_in_tier * cost_per_token - tokens_processed = tier_end - - # After loop, check if any tokens remain (i.e., tokens > highest tier's end range) - # and charge them at the last tier's rate. - if tokens_processed < tokens and sorted_tiers: - last_tier = sorted_tiers[-1] - remaining_tokens = tokens - tokens_processed - cost_per_token = last_tier.get(cost_key) or last_tier.get(fallback_cost_key, 0) - total_cost += remaining_tokens * cost_per_token - - return total_cost - - def _calculate_prompt_cost( breakdown: TokenBreakdown, model_info: ModelInfo, @@ -123,12 +50,12 @@ def _calculate_prompt_cost( ) -> float: """Calculate total prompt cost including cached tokens.""" if tiered_pricing: - text_cost = _calculate_tiered_cost( + text_cost = calculate_tiered_cost( tokens=breakdown.text_tokens, tiered_pricing=tiered_pricing, cost_key="input_cost_per_token", ) - cache_cost = _calculate_tiered_cost( + cache_cost = calculate_tiered_cost( tokens=breakdown.cached_tokens, tiered_pricing=tiered_pricing, cost_key="cache_read_input_token_cost", @@ -155,12 +82,12 @@ def _calculate_completion_cost( ) -> float: """Calculate total completion cost including reasoning tokens.""" if tiered_pricing: - completion_cost = _calculate_tiered_cost( + completion_cost = calculate_tiered_cost( tokens=breakdown.completion_tokens, tiered_pricing=tiered_pricing, cost_key="output_cost_per_token", ) - reasoning_cost = _calculate_tiered_cost( + reasoning_cost = calculate_tiered_cost( tokens=breakdown.reasoning_tokens, tiered_pricing=tiered_pricing, cost_key="output_cost_per_reasoning_token", diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index ba8c312ea51..9c05899c719 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -181,6 +181,10 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): if key != "self" and value is not None: setattr(self.__class__, key, value) + @property + def custom_llm_provider(self) -> Optional[str]: + return "databricks" + @classmethod def get_config(cls): return super().get_config() @@ -372,6 +376,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): mapped_thinking = AnthropicConfig._map_reasoning_effort( reasoning_effort=reasoning_effort_value, model=model, + custom_llm_provider="databricks", llm_provider="databricks", ) if mapped_thinking is None: @@ -379,7 +384,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): optional_params.pop("output_config", None) else: optional_params["thinking"] = mapped_thinking - if AnthropicConfig._is_adaptive_thinking_model(model): + if AnthropicConfig._is_adaptive_thinking_model(model, "databricks"): mapped_effort: Optional[str] = None if isinstance(reasoning_effort_value, str): mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(reasoning_effort_value) diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index 7a548136f2a..525de1476e2 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -35,7 +35,9 @@ class DeepSeekChatConfig(OpenAIGPTConfig): Map OpenAI params to DeepSeek params. Handles `thinking` and `reasoning_effort` parameters for DeepSeek reasoner models. - DeepSeek only supports `{"type": "enabled"}` - no budget_tokens like Anthropic. + DeepSeek supports `{"type": "enabled"}` and `{"type": "disabled"}` - no budget_tokens + like Anthropic. `reasoning_effort="none"` is the OpenAI-style way to ask for thinking + off, so it maps to `{"type": "disabled"}`; any other effort keeps thinking on. Reference: https://api-docs.deepseek.com/guides/thinking_mode """ @@ -47,15 +49,13 @@ class DeepSeekChatConfig(OpenAIGPTConfig): thinking_value = optional_params.pop("thinking", None) reasoning_effort = optional_params.pop("reasoning_effort", None) - # Handle thinking parameter - only accept {"type": "enabled"} - if thinking_value is not None: - if isinstance(thinking_value, dict) and thinking_value.get("type") == "enabled": - # DeepSeek only accepts {"type": "enabled"}, ignore budget_tokens - optional_params["thinking"] = {"type": "enabled"} + # Handle thinking parameter - accept both enabled and disabled, ignore budget_tokens + if isinstance(thinking_value, dict) and thinking_value.get("type") in ("enabled", "disabled"): + optional_params["thinking"] = {"type": thinking_value["type"]} - # Handle reasoning_effort - map to thinking enabled - elif reasoning_effort is not None and reasoning_effort != "none": - optional_params["thinking"] = {"type": "enabled"} + # Otherwise fall back to reasoning_effort: "none" disables, anything else enables + elif reasoning_effort is not None: + optional_params["thinking"] = {"type": "disabled" if reasoning_effort == "none" else "enabled"} return optional_params diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index fb3f0a4e159..4d7b003c48f 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -25,6 +25,10 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): super().__init__() self.authenticator = Authenticator() + @property + def custom_llm_provider(self) -> Optional[str]: + return "github_copilot" + def handles_web_search_natively(self) -> bool: """ Copilot's /v1/messages endpoint does not execute ``web_search`` tools, so diff --git a/litellm/llms/oci/chat/cohere.py b/litellm/llms/oci/chat/cohere.py index 3661ac908d2..ca85a6309d7 100644 --- a/litellm/llms/oci/chat/cohere.py +++ b/litellm/llms/oci/chat/cohere.py @@ -110,7 +110,7 @@ def adapt_messages_to_cohere_standard( tool_calls: Optional[List[CohereToolCall]] = None if role == "assistant" and msg.get("tool_calls"): # type: ignore[union-attr,typeddict-item] tool_calls = [] - for tc in msg["tool_calls"]: # type: ignore[union-attr,typeddict-item] + for tc in msg["tool_calls"]: # pyright: ignore[reportOptionalIterable] # truthiness check above rules out None raw_arguments: Any = tc.get("function", {}).get("arguments", {}) if isinstance(raw_arguments, str): try: diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index 3152ded2367..694f8cdd6c2 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -363,7 +363,11 @@ class OllamaChatConfig(BaseConfig): response_json_message["reasoning_content"] = reasoning_content response_json_message["content"] = content - if request_data.get("format", "") == "json" and litellm_params.get("function_name") is not None: + if ( + request_data.get("format", "") == "json" + and litellm_params.get("function_name") is not None + and response_json_message is not None + ): function_call = json.loads(response_json_message["content"]) message = litellm.Message( content=None, diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index 396ad5b105e..f2498c0a7e2 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -2,6 +2,7 @@ Support for gpt model family """ +import json from typing import ( TYPE_CHECKING, Any, @@ -782,8 +783,30 @@ class OpenAIChatCompletionStreamingHandler(BaseModelResponseIterator): delta["reasoning_content"] = delta.pop("reasoning") return choices + @staticmethod + def _extract_error_from_chunk(chunk: dict) -> Optional[tuple[str, int]]: + """OpenAI-compatible backends (vLLM, sglang) can return an HTTP 200 + stream whose body carries an error payload, e.g. + ``data: {"error": {"message": "...", "code": 400}}``.""" + error = chunk.get("error") + if not error: + return None + if not isinstance(error, dict): + return str(error), 500 + message = error.get("message") + code = error.get("code") + status_code = code if isinstance(code, int) and 400 <= code < 600 else 500 + return (message if isinstance(message, str) else json.dumps(error)), status_code + def chunk_parser(self, chunk: dict) -> ModelResponseStream: try: + error_details = self._extract_error_from_chunk(chunk) + if error_details is not None: + error_message, error_status_code = error_details + raise OpenAIError( + status_code=error_status_code, + message=error_message, + ) choices = chunk.get("choices", []) choices = self._map_reasoning_to_reasoning_content(choices) diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index c7a49a2e47f..fd2c9339248 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -14,11 +14,14 @@ Pattern Overview: This pattern can be replicated for other message formats (e.g., Anthropic). """ -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Union, cast import litellm from litellm._logging import verbose_proxy_logger -from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation +from litellm.llms.base_llm.guardrail_translation.base_translation import ( + BaseTranslation, + StreamTransformSink, +) from litellm.llms.base_llm.guardrail_translation.utils import ( effective_skip_system_message_for_guardrail, effective_skip_tool_message_for_guardrail, @@ -27,6 +30,9 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( ) from litellm.main import stream_chunk_builder from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam +from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( + coerce_stream_holdback_value, +) from litellm.types.utils import ( Choices, GenericGuardrailAPIInputs, @@ -50,7 +56,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): Methods can be overridden to customize behavior for different message formats. """ - def get_structured_messages(self, data: dict) -> Optional[List[AllMessageValues]]: + def get_structured_messages(self, data: dict) -> List[AllMessageValues] | None: """ Convert chat completions request data to OpenAI-spec structured messages. @@ -65,7 +71,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, data: dict, guardrail_to_apply: "CustomGuardrail", - litellm_logging_obj: Optional[Any] = None, + litellm_logging_obj: Any | None = None, ) -> Any: """ Process input messages by applying guardrails to text content. @@ -80,7 +86,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): texts_to_check: List[str] = [] images_to_check: List[str] = [] tool_calls_to_check: List[ChatCompletionToolParam] = [] - text_task_mappings: List[Tuple[int, Optional[int]]] = [] + text_task_mappings: List[Tuple[int, int | None]] = [] tool_call_task_mappings: List[Tuple[int, int]] = [] # Step 1: Extract all text content, images, and tool calls @@ -184,7 +190,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): texts_to_check: List[str], images_to_check: List[str], tool_calls_to_check: List[ChatCompletionToolParam], - text_task_mappings: List[Tuple[int, Optional[int]]], + text_task_mappings: List[Tuple[int, int | None]], tool_call_task_mappings: List[Tuple[int, int]], skip_system_message: bool = False, skip_tool_message: bool = False, @@ -239,7 +245,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, messages: List[Dict[str, Any]], responses: List[str], - task_mappings: List[Tuple[int, Optional[int]]], + task_mappings: List[Tuple[int, int | None]], ) -> None: """ Apply guardrail responses back to input message text content. @@ -249,7 +255,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): for task_idx, guardrail_response in enumerate(responses): mapping = task_mappings[task_idx] msg_idx = cast(int, mapping[0]) - content_idx_optional = cast(Optional[int], mapping[1]) + content_idx_optional = cast(int | None, mapping[1]) # Handle content content = messages[msg_idx].get("content", None) @@ -291,9 +297,9 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, response: "ModelResponse", guardrail_to_apply: "CustomGuardrail", - litellm_logging_obj: Optional[Any] = None, - user_api_key_dict: Optional[Any] = None, - request_data: Optional[dict] = None, + litellm_logging_obj: Any | None = None, + user_api_key_dict: Any | None = None, + request_data: dict | None = None, ) -> Any: """ Process output response by applying guardrails to text content. @@ -320,7 +326,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): texts_to_check: List[str] = [] images_to_check: List[str] = [] tool_calls_to_check: List[Dict[str, Any]] = [] - text_task_mappings: List[Tuple[int, Optional[int]]] = [] + text_task_mappings: List[Tuple[int, int | None]] = [] tool_call_task_mappings: List[Tuple[int, int]] = [] # text_task_mappings: Track (choice_index, content_index) for each text # content_index is None for string content, int for list content @@ -402,9 +408,10 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, responses_so_far: List["ModelResponseStream"], guardrail_to_apply: "CustomGuardrail", - litellm_logging_obj: Optional[Any] = None, - user_api_key_dict: Optional[Any] = None, - request_data: Optional[dict] = None, + litellm_logging_obj: Any | None = None, + user_api_key_dict: Any | None = None, + request_data: dict | None = None, + stream_transform_sink: StreamTransformSink | None = None, ) -> List["ModelResponseStream"]: """ Process output streaming responses by applying guardrails to text content. @@ -414,14 +421,50 @@ class OpenAIChatCompletionsHandler(BaseTranslation): guardrail_to_apply: The guardrail instance to apply litellm_logging_obj: Optional logging object user_api_key_dict: User API key metadata to pass to guardrails + stream_transform_sink: Optional out-parameter for the streaming text + transformation path. When provided, the guardrail runs over the raw + accumulated text (``responses_so_far`` is left untouched so it stays + a correct raw accumulator across rounds) and the guardrailed text + plus requested holdback are reported per choice on the sink. Returns: - Modified list of responses with guardrail applied to content + The (unmodified) list of responses. Response Format Support: - String content: choice.message.content = "text here" - List content: choice.message.content = [{"type": "text", "text": "text here"}, ...] """ + if stream_transform_sink is not None: + await self._process_streaming_transform( + responses_so_far=responses_so_far, + guardrail_to_apply=guardrail_to_apply, + litellm_logging_obj=litellm_logging_obj, + user_api_key_dict=user_api_key_dict, + request_data=request_data, + sink=stream_transform_sink, + ) + return responses_so_far + + return await self._process_streaming_block_only( + responses_so_far=responses_so_far, + guardrail_to_apply=guardrail_to_apply, + litellm_logging_obj=litellm_logging_obj, + user_api_key_dict=user_api_key_dict, + request_data=request_data, + ) + + async def _process_streaming_block_only( + self, + *, + responses_so_far: list["ModelResponseStream"], + guardrail_to_apply: "CustomGuardrail", + litellm_logging_obj: Any | None, + user_api_key_dict: Any | None, + request_data: dict | None, + ) -> list["ModelResponseStream"]: + """Block-only streaming path: run the guardrail so an in-flight BLOCK can + terminate the stream. Text rewrites are not propagated to the client here + (see ``_process_streaming_transform`` for the incremental_diff path).""" # check if the stream has ended has_stream_ended = False for chunk in responses_so_far: @@ -467,7 +510,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): # Step 2: Create lists for guardrail processing texts_to_check: List[str] = [] images_to_check: List[str] = [] - task_mappings: List[Tuple[int, Optional[int]]] = [] + task_mappings: List[Tuple[int, int | None]] = [] # Track (choice_index, content_index) for each combined text for (map_choice_idx, map_content_idx), combined_text in combined_texts.items(): @@ -520,9 +563,109 @@ class OpenAIChatCompletionsHandler(BaseTranslation): return responses_so_far + @staticmethod + def _accumulate_string_content_by_choice_index( + responses_so_far: list["ModelResponseStream"], + ) -> dict[int, str]: + """Accumulate raw string ``delta.content`` per choice, keyed by + ``StreamingChoices.index`` (not enumerate position, which collapses to 0 + when each chunk carries a single non-zero-indexed choice for ``n > 1``). + + Only string content participates; list-of-blocks content is out of scope + for the incremental transform path. Reads ``responses_so_far`` without + mutating it so it stays a correct raw accumulator across rounds. + """ + accumulated: dict[int, str] = {} + for response in responses_so_far: + for choice in response.choices: + if isinstance(choice, litellm.StreamingChoices): + content = choice.delta.content + elif isinstance(choice, litellm.Choices): + content = choice.message.content + else: + continue + if isinstance(content, str) and content: + idx = getattr(choice, "index", 0) or 0 + accumulated[idx] = accumulated.get(idx, "") + content + return accumulated + + async def _process_streaming_transform( + self, + *, + responses_so_far: list["ModelResponseStream"], + guardrail_to_apply: "CustomGuardrail", + litellm_logging_obj: Any | None, + user_api_key_dict: Any | None, + request_data: dict | None, + sink: StreamTransformSink, + ) -> None: + """Run the guardrail over the raw accumulated text and report the + guardrailed text plus requested holdback per choice on ``sink``. + + Unlike the block-only path this never mutates ``responses_so_far``: it + re-derives the raw accumulated text every round (so a rewrite guardrail + always sees consistent input) and hands the result back out of band. + """ + raw_by_index = self._accumulate_string_content_by_choice_index(responses_so_far) + if not raw_by_index: + sink.mutated_text_per_choice = {} + sink.holdback_per_choice = {} + return + + # Fix #2 — sort by StreamingChoices.index so an n>1 stream that emits + # choice 1 before choice 0 still hands the guardrail texts in a + # deterministic index order. Without this, the guardrail's returned + # texts (aligned to the input order it received) would map back to the + # wrong choice indices when we rebuild the sink dicts by + # ``enumerate(indices)``. + indices = sorted(raw_by_index.keys()) + texts_to_check = [raw_by_index[i] for i in indices] + + if request_data is None: + request_data = {"responses": responses_so_far} + elif "responses" not in request_data: + request_data["responses"] = responses_so_far + if "litellm_metadata" not in request_data: + user_metadata = self.transform_user_api_key_dict_to_metadata(user_api_key_dict) + if user_metadata: + request_data["litellm_metadata"] = user_metadata + + inputs = GenericGuardrailAPIInputs(texts=texts_to_check) + if responses_so_far and getattr(responses_so_far[0], "model", None): + inputs["model"] = responses_so_far[0].model + guardrailed_inputs = await guardrail_to_apply.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="response", + logging_obj=litellm_logging_obj, + ) + + returned_texts = guardrailed_inputs.get("texts") + # No "texts" key means the guardrail made no change (action NONE): the raw + # accumulated text is the guardrailed text. A present-but-shorter list is a + # guardrail contract violation; those choices are omitted below (withheld, + # not emitted raw) so a malformed response fails closed instead of leaking. + if returned_texts is None: + returned_texts = texts_to_check + elif len(returned_texts) < len(texts_to_check): + verbose_proxy_logger.warning( + "OpenAI Chat Completions: guardrail returned %s transformed texts for %s inputs on the " + "streaming transform path; withholding the unmatched choices to fail closed.", + len(returned_texts), + len(texts_to_check), + ) + + holdback = guardrailed_inputs.get("stream_holdback_chars") or [] + sink.mutated_text_per_choice = { + idx: returned_texts[i] for i, idx in enumerate(indices) if i < len(returned_texts) + } + sink.holdback_per_choice = { + indices[i]: coerce_stream_holdback_value(holdback[i]) for i in range(len(indices)) if i < len(holdback) + } + def _combine_streaming_texts( self, responses_so_far: List["ModelResponseStream"] - ) -> Dict[Tuple[int, Optional[int]], str]: + ) -> Dict[Tuple[int, int | None], str]: """ Combine all streaming chunks into complete text per choice. @@ -534,7 +677,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): Returns: Dict mapping (choice_idx, content_idx) to combined text string """ - combined_texts: Dict[Tuple[int, Optional[int]], str] = {} + combined_texts: Dict[Tuple[int, int | None], str] = {} for response_idx, response in enumerate(responses_so_far): for choice_idx, choice in enumerate(response.choices): @@ -550,7 +693,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): if isinstance(content, str): # String content - accumulate for this choice - str_key: Tuple[int, Optional[int]] = (choice_idx, None) + str_key: Tuple[int, int | None] = (choice_idx, None) if str_key not in combined_texts: combined_texts[str_key] = "" combined_texts[str_key] += content @@ -560,7 +703,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): for content_idx, content_item in enumerate(content): text_str = content_item.get("text") if text_str: - list_key: Tuple[int, Optional[int]] = ( + list_key: Tuple[int, int | None] = ( choice_idx, content_idx, ) @@ -607,7 +750,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): texts_to_check: List[str], images_to_check: List[str], tool_calls_to_check: List[Dict[str, Any]], - text_task_mappings: List[Tuple[int, Optional[int]]], + text_task_mappings: List[Tuple[int, int | None]], tool_call_task_mappings: List[Tuple[int, int]], ) -> None: """ @@ -619,7 +762,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): # Determine content source and tool calls based on choice type content = None - tool_calls: Optional[List[Any]] = None + tool_calls: List[Any] | None = None if isinstance(choice, litellm.Choices): content = choice.message.content tool_calls = choice.message.tool_calls @@ -662,7 +805,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): tool_calls_to_check.append(tool_call_dict) tool_call_task_mappings.append((choice_idx, int(tool_call_idx))) - def _convert_tool_call_to_dict(self, tool_call: Union[Dict[str, Any], Any]) -> Optional[Dict[str, Any]]: + def _convert_tool_call_to_dict(self, tool_call: Union[Dict[str, Any], Any]) -> Dict[str, Any] | None: """ Convert a tool call object to dictionary format. @@ -691,7 +834,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, response: "ModelResponse", responses: List[str], - task_mappings: List[Tuple[int, Optional[int]]], + task_mappings: List[Tuple[int, int | None]], ) -> None: """ Apply guardrail text responses back to output response. @@ -701,7 +844,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): for task_idx, guardrail_response in enumerate(responses): mapping = task_mappings[task_idx] choice_idx = cast(int, mapping[0]) - content_idx_optional = cast(Optional[int], mapping[1]) + content_idx_optional = cast(int | None, mapping[1]) choice = cast(Choices, response.choices[choice_idx]) @@ -716,7 +859,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): elif isinstance(content, list) and content_idx_optional is not None: # Replace specific text item in list content - choice.message.content[content_idx_optional]["text"] = guardrail_response # type: ignore + content[content_idx_optional]["text"] = guardrail_response async def _apply_guardrail_responses_to_output_tool_calls( self, @@ -755,7 +898,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): self, responses: List["ModelResponseStream"], guardrailed_texts: List[str], - task_mappings: List[Tuple[int, Optional[int]]], + task_mappings: List[Tuple[int, int | None]], ) -> None: """ Apply guardrail responses back to output streaming responses. @@ -771,16 +914,16 @@ class OpenAIChatCompletionsHandler(BaseTranslation): Override this method to customize how responses are applied to streaming responses. """ # Build a mapping of what guardrailed text to use for each (choice_idx, content_idx) - guardrail_map: Dict[Tuple[int, Optional[int]], str] = {} + guardrail_map: Dict[Tuple[int, int | None], str] = {} for task_idx, guardrail_response in enumerate(guardrailed_texts): mapping = task_mappings[task_idx] choice_idx = cast(int, mapping[0]) - content_idx_optional = cast(Optional[int], mapping[1]) + content_idx_optional = cast(int | None, mapping[1]) guardrail_map[(choice_idx, content_idx_optional)] = guardrail_response # Track which choices we've already set the guardrailed text for # Key: (choice_idx, content_idx), Value: boolean (True if already set) - already_set: Dict[Tuple[int, Optional[int]], bool] = {} + already_set: Dict[Tuple[int, int | None], bool] = {} # Iterate through all responses and update content for response_idx, response in enumerate(responses): @@ -797,7 +940,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): if isinstance(content, str): # String content - str_key: Tuple[int, Optional[int]] = (choice_idx_in_response, None) + str_key: Tuple[int, int | None] = (choice_idx_in_response, None) if str_key in guardrail_map: if str_key not in already_set: # First chunk - set the complete guardrailed text @@ -817,7 +960,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): # List content - handle each content item for content_idx, content_item in enumerate(content): if "text" in content_item: - list_key: Tuple[int, Optional[int]] = ( + list_key: Tuple[int, int | None] = ( choice_idx_in_response, content_idx, ) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 6ac33ffa44a..093dffccac0 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -45,6 +45,7 @@ from litellm.types.llms.openai import ( AllMessageValues, ChatCompletionToolCallChunk, ChatCompletionToolParam, + ResponsesAPIStreamEvents, ) from litellm.types.responses.main import ( GenericResponseOutputItem, @@ -196,12 +197,13 @@ class OpenAIResponsesHandler(BaseTranslation): return data def extract_request_tool_names(self, data: dict) -> List[str]: - """Extract tool names from Responses API request (tools[].name for function, tools[].server_label for mcp).""" + """Extract tool names from Responses API request (tools[].name for function + and custom, tools[].server_label for mcp).""" names: List[str] = [] for tool in data.get("tools") or []: if not isinstance(tool, dict): continue - if tool.get("type") == "function" and tool.get("name"): + if tool.get("type") in ("function", "custom") and tool.get("name"): names.append(str(tool["name"])) elif tool.get("type") == "mcp" and tool.get("server_label"): names.append(str(tool["server_label"])) @@ -586,7 +588,14 @@ class OpenAIResponsesHandler(BaseTranslation): """ Check if the streaming has ended. """ - return all(response.choices[0].finish_reason is not None for response in responses_so_far) + if not responses_so_far: + return False + terminal_types = { + ResponsesAPIStreamEvents.RESPONSE_COMPLETED.value, + ResponsesAPIStreamEvents.RESPONSE_FAILED.value, + ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE.value, + } + return responses_so_far[-1].get("type") in terminal_types def get_streaming_string_so_far(self, responses_so_far: List[Any]) -> str: """ diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index d107ca7a0d7..3c2ae238a0b 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -20,6 +20,8 @@ from litellm.types.utils import LlmProviders from ..common_utils import OpenAIError +OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS = 16 + if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -59,6 +61,19 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): key="supports_none_reasoning_effort", ) + @staticmethod + def _enforce_min_max_output_tokens(max_output_tokens: "int | None") -> "int | None": + """Raise sub-minimum max_output_tokens up to the OpenAI Responses API minimum. + + OpenAI's Responses API rejects max_output_tokens below 16 for every model + (not gpt-5 specific), so a client like Claude Code that sends a max_tokens=1 + warmup probe on model switch would otherwise 400. Values that are None or + already at/above the minimum are returned unchanged. + """ + if isinstance(max_output_tokens, int) and max_output_tokens < OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS: + return OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS + return max_output_tokens + def get_supported_openai_params(self, model: str) -> list: """ All OpenAI Responses API params are supported @@ -92,6 +107,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): """ params = dict(response_api_optional_params) + if "max_output_tokens" in params: + params["max_output_tokens"] = self._enforce_min_max_output_tokens(params.get("max_output_tokens")) + if self._is_gpt_5_model(model=model): temperature = params.get("temperature") if temperature is not None and temperature != 1: diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 3c763ed9b9b..31c913d5d4e 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -91,7 +91,7 @@ def create_config_class(provider: SimpleProviderConfig): def get_supported_openai_params(self, model: str) -> list: """Get supported OpenAI params, excluding tool-related params for models that don't support function calling.""" - from litellm.utils import supports_function_calling + from litellm.utils import supports_function_calling, supports_reasoning supported_params = super().get_supported_openai_params(model=model) @@ -113,6 +113,10 @@ def create_config_class(provider: SimpleProviderConfig): f"function calling — removed tool-related params from supported params." ) + _supports_reasoning = supports_reasoning(model=model, custom_llm_provider=provider.slug) + if _supports_reasoning and "reasoning_effort" not in supported_params: + supported_params.append("reasoning_effort") + return supported_params def map_openai_params( diff --git a/litellm/llms/openai_like/messages/transformation.py b/litellm/llms/openai_like/messages/transformation.py index 0df8c6e830b..0d593d8d0f4 100644 --- a/litellm/llms/openai_like/messages/transformation.py +++ b/litellm/llms/openai_like/messages/transformation.py @@ -1,8 +1,11 @@ from typing import Any, Optional +import litellm from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( AnthropicMessagesConfig, ) +from litellm.llms.openai_like.json_loader import SimpleProviderConfig +from litellm.secret_managers.main import get_secret_str DEFAULT_ANTHROPIC_API_VERSION = "2023-06-01" @@ -67,3 +70,69 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig): if base.endswith("/v1"): base = base[: -len("/v1")] return f"{base}/v1/messages" + + +class JSONProviderAnthropicMessagesConfig(OpenAILikeAnthropicMessagesConfig): + """ + Provider-level native Anthropic Messages passthrough for JSON-configured + OpenAI-compatible providers whose ``supported_endpoints`` in providers.json + includes ``"/v1/messages"``. Resolves the api key and api base from the + provider's configured env vars, then forwards the Anthropic payload + untranslated like ``OpenAILikeAnthropicMessagesConfig``. + """ + + def __init__(self, provider: SimpleProviderConfig): + super().__init__() + self._provider = provider + + @property + def custom_llm_provider(self) -> Optional[str]: + return self._provider.slug + + def should_strip_billing_metadata(self) -> bool: + return True + + def _resolve_api_key(self, api_key: Optional[str]) -> Optional[str]: + return api_key or get_secret_str(self._provider.api_key_env) or litellm.api_key + + def _resolve_api_base(self, api_base: Optional[str]) -> str: + env_api_base = get_secret_str(self._provider.api_base_env) if self._provider.api_base_env else None + return api_base or env_api_base or self._provider.base_url + + def validate_anthropic_messages_environment( + self, + headers: dict[str, str], + model: str, + messages: list[Any], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> tuple[dict[str, str], Optional[str]]: + return super().validate_anthropic_messages_environment( + headers=headers, + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + api_key=self._resolve_api_key(api_key), + api_base=api_base, + ) + + 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 super().get_complete_url( + api_base=self._resolve_api_base(api_base), + api_key=api_key, + model=model, + optional_params=optional_params, + litellm_params=litellm_params, + stream=stream, + ) diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index d87346fea70..164100d4194 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -168,6 +168,13 @@ }, "supported_endpoints": ["/v1/chat/completions", "/v1/responses"] }, + "meta": { + "base_url": "https://api.meta.ai/v1", + "api_key_env": "META_API_KEY", + "api_base_env": "META_API_BASE", + "base_class": "openai_gpt", + "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"] + }, "pinstripes": { "base_url": "https://pinstripes.io/v1", "api_key_env": "PINSTRIPES_API_KEY", diff --git a/litellm/llms/sap/chat/transformation.py b/litellm/llms/sap/chat/transformation.py index d9d0f9bc236..4bf8272a334 100755 --- a/litellm/llms/sap/chat/transformation.py +++ b/litellm/llms/sap/chat/transformation.py @@ -155,7 +155,7 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): def headers(self) -> Dict[str, str]: if self.token_creator is None: self.run_env_setup() - access_token = self.token_creator() # type: ignore + access_token = self.token_creator() # pyright: ignore[reportOptionalCall] # run_env_setup set it or raised return { "Authorization": access_token, "AI-Resource-Group": self.resource_group, diff --git a/litellm/llms/tencent/__init__.py b/litellm/llms/tencent/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/tencent/chat/__init__.py b/litellm/llms/tencent/chat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/tencent/chat/transformation.py b/litellm/llms/tencent/chat/transformation.py new file mode 100644 index 00000000000..4dea0c4b8c7 --- /dev/null +++ b/litellm/llms/tencent/chat/transformation.py @@ -0,0 +1,68 @@ +""" +Translates from OpenAI's `/v1/chat/completions` to Tencent TokenHub's +OpenAI-compatible endpoint. +""" + +from typing import Optional + +from litellm.secret_managers.main import get_secret_str +from litellm.utils import supports_reasoning + +from ...openai.chat.gpt_transformation import OpenAIGPTConfig + + +class TencentChatConfig(OpenAIGPTConfig): + def get_supported_openai_params(self, model: str) -> list: + params = super().get_supported_openai_params(model) + if supports_reasoning(model, custom_llm_provider="tencent"): + params.extend(["thinking", "reasoning_effort"]) + return params + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + optional_params = super().map_openai_params(non_default_params, optional_params, model, drop_params) + + thinking_value = optional_params.pop("thinking", None) + reasoning_effort = optional_params.pop("reasoning_effort", None) + + if thinking_value is not None: + if isinstance(thinking_value, dict): + optional_params["thinking"] = thinking_value + elif reasoning_effort is not None and reasoning_effort != "none": + optional_params["thinking"] = {"type": "enabled"} + + return optional_params + + def _get_openai_compatible_provider_info( + self, api_base: Optional[str], api_key: Optional[str] + ) -> tuple[Optional[str], Optional[str]]: + api_base = api_base or get_secret_str("TENCENT_API_BASE") or "https://tokenhub-intl.tencentcloudmaas.com/v1" + dynamic_api_key = api_key or get_secret_str("TENCENT_API_KEY") + return api_base, dynamic_api_key + + 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: + if not api_base: + api_base = "https://tokenhub-intl.tencentcloudmaas.com/v1" + + api_base = api_base.rstrip("/") + + if api_base.endswith("/chat/completions"): + return api_base + + if not api_base.endswith("/v1"): + api_base = f"{api_base}/v1" + + return f"{api_base}/chat/completions" diff --git a/litellm/llms/tencent/cost_calculator.py b/litellm/llms/tencent/cost_calculator.py new file mode 100644 index 00000000000..d9aebdc3284 --- /dev/null +++ b/litellm/llms/tencent/cost_calculator.py @@ -0,0 +1,6 @@ +from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token +from litellm.types.utils import Usage + + +def cost_per_token(model: str, usage: Usage) -> tuple[float, float]: + return generic_cost_per_token(model=model, usage=usage, custom_llm_provider="tencent") diff --git a/litellm/llms/tencent/messages/__init__.py b/litellm/llms/tencent/messages/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/tencent/messages/transformation.py b/litellm/llms/tencent/messages/transformation.py new file mode 100644 index 00000000000..e0f13aa9ca4 --- /dev/null +++ b/litellm/llms/tencent/messages/transformation.py @@ -0,0 +1,85 @@ +""" +Tencent Anthropic-compatible messages transformation config. + +Tencent TokenHub exposes an Anthropic-compatible Messages API endpoint +alongside its standard OpenAI-compatible chat completions endpoint. +""" + +from typing import Any, Optional + +import litellm +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) +from litellm.secret_managers.main import get_secret_str + + +class TencentAnthropicMessagesConfig(AnthropicMessagesConfig): + """ + Tencent TokenHub exposes an Anthropic-compatible Messages API. + + Unlike the chat completions endpoint (which uses /v1), the Anthropic + endpoint may use a different base URL. Configure via + TENCENT_ANTHROPIC_API_BASE or TENCENT_API_BASE. + """ + + @property + def custom_llm_provider(self) -> Optional[str]: + return "tencent" + + def should_strip_billing_metadata(self) -> bool: + return True + + @staticmethod + def get_api_key(api_key: Optional[str] = None) -> Optional[str]: + return api_key or get_secret_str("TENCENT_API_KEY") or litellm.api_key + + @staticmethod + def get_api_base(api_base: Optional[str] = None) -> str: + return ( + api_base + or get_secret_str("TENCENT_ANTHROPIC_API_BASE") + or get_secret_str("TENCENT_API_BASE") + or "https://tokenhub-intl.tencentcloudmaas.com" + ) + + def validate_anthropic_messages_environment( + self, + headers: dict, + model: str, + messages: list[Any], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> tuple[dict, Optional[str]]: + return super().validate_anthropic_messages_environment( + headers=headers, + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + api_key=self.get_api_key(api_key=api_key), + api_base=api_base, + ) + + 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: + base_url = self.get_api_base(api_base=api_base).rstrip("/") + + if base_url.endswith("/v1/messages"): + return base_url + + if base_url.endswith("/v1/chat/completions"): + base_url = base_url[: -len("/v1/chat/completions")] + elif base_url.endswith("/v1"): + base_url = base_url[: -len("/v1")] + + return f"{base_url}/v1/messages" diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index 4b7d38e3661..cef5f9cd02e 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -6,53 +6,42 @@ Docs: https://docs.tinyfish.ai/search-api from __future__ import annotations -from typing import Literal, TypedDict +import json +from typing import Literal from urllib.parse import urlencode import httpx -from pydantic import BaseModel, TypeAdapter, ValidationError +from pydantic import TypeAdapter, ValidationError +from litellm._logging import verbose_logger from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.search.transformation import ( BaseSearchConfig, SearchResponse, - SearchResult, ) from litellm.secret_managers.main import get_secret_str - -class _TinyfishSearchRequestRequired(TypedDict): - query: str - - -class TinyfishSearchRequest(_TinyfishSearchRequestRequired, total=False): - location: str - language: str - page: int - include_thumbnail: bool - max_results: int - - -class _TinyfishResultItem(BaseModel, frozen=True): - title: str = "" - url: str = "" - snippet: str = "" - - -class _TinyfishApiResponse(BaseModel, frozen=True): - results: tuple[_TinyfishResultItem, ...] = () - - _UrlEncodableParams = TypeAdapter(dict[str, str | int | bool]) _StrList = TypeAdapter(list[str]) _StrFrozenSet = TypeAdapter(frozenset[str]) _TINYFISH_PARAMS_KEY = "_tinyfish_params" +_TINYFISH_DOCS_URL = "https://docs.tinyfish.ai/search-api" +_TINYFISH_RESULT_CAP = 10 # TinyFish's natural per-page SERP ceiling class TinyfishSearchConfig(BaseSearchConfig): TINYFISH_API_BASE = "https://api.search.tinyfish.ai" + def __init__(self) -> None: + super().__init__() + # Threaded from transform_search_request → transform_search_response so the + # response slice honors the caller's max_results without re-sending it on + # the wire (TinyFish doesn't honor it server-side). Safe because the + # config is instantiated per-call via ProviderConfigManager. + self._caller_max_results: int | None = None + @staticmethod def ui_friendly_name() -> str: return "TinyFish" @@ -97,36 +86,77 @@ class TinyfishSearchConfig(BaseSearchConfig): optional_params: dict[str, object], **kwargs: object, ) -> dict[str, object]: + """ + Transform a LiteLLM search request to TinyFish's querystring format. + + Maps LiteLLM's unified-spec params (see + ``BaseSearchConfig.get_supported_perplexity_optional_params``) to + TinyFish equivalents: + - ``query`` (str or list[str]) → ``query`` (list joined by spaces) + - ``country`` → ``location`` + - ``search_domain_filter`` (list[str]) → folded into the query as + ``() (site:a OR site:b ...)`` (TinyFish has no first-class + field today; see ML-2084 for the planned ``include_domains``) + - ``max_results`` → not sent on the wire; stashed on + ``self._caller_max_results`` for client-side response truncation + (TinyFish doesn't honor it server-side) + - ``max_tokens_per_page`` → silently dropped (no TinyFish equivalent) + + Any other ``optional_params`` keys are forwarded to TinyFish as-is. + dict/list values are JSON-encoded so they survive ``urlencode``. + + Returns: + ``{_TINYFISH_PARAMS_KEY: }``. + ``get_complete_url`` reads this back to build the final URL. + """ resolved_query = " ".join(query) if isinstance(query, list) else query - request_data: TinyfishSearchRequest = {"query": resolved_query} - - country = optional_params.get("country") - if isinstance(country, str): - request_data["location"] = country - - raw_max = optional_params.get("max_results") - if isinstance(raw_max, (int, float, str)): - request_data["max_results"] = max(1, min(int(raw_max), 20)) - try: domains = _StrList.validate_python(optional_params.get("search_domain_filter")) except (ValidationError, TypeError): domains = [] if domains: - request_data["query"] = _append_domain_filters(request_data["query"], domains) + resolved_query = _append_domain_filters(resolved_query, domains) - result_data: dict[str, object] = dict(request_data) + request_data: dict[str, object] = {"query": resolved_query} + + country = optional_params.get("country") + if isinstance(country, str): + request_data["location"] = country + + # max_results is enforced client-side on the response (TinyFish ignores + # the param and always returns ~10). Clamp to [1, 10] and stash on self + # so transform_search_response can slice without re-reading the URL. + raw_max = optional_params.get("max_results") + if isinstance(raw_max, (int, float, str)): + try: + self._caller_max_results = max(1, min(int(raw_max), _TINYFISH_RESULT_CAP)) + except (ValueError, TypeError, OverflowError): + # OverflowError covers int(float('inf')) and similar non-finite floats. + verbose_logger.warning( + "TinyFish Search: max_results=%r is not a valid integer; ignoring.", + raw_max, + ) raw_supported: object = ( self.get_supported_perplexity_optional_params() # any-ok: base class returns bare set ) supported_perplexity = _StrFrozenSet.validate_python(raw_supported) for param, value in optional_params.items(): - if param not in supported_perplexity and param not in result_data: - result_data[param] = value + if param not in supported_perplexity and param not in request_data: + # `fetch` expects a JSON-encoded object on the wire; accept the + # natural Python dict form and serialize here so callers don't + # have to pre-stringify. + if isinstance(value, dict): + value = json.dumps(value, separators=(",", ":")) + # `urlencode` would render Python bool as "True"/"False" + # (capitalized). ux-labs validators require lowercase + # "true"/"false" (e.g. `include_thumbnail`); normalize here. + elif isinstance(value, bool): + value = "true" if value else "false" + request_data[param] = value - return {_TINYFISH_PARAMS_KEY: result_data} + return {_TINYFISH_PARAMS_KEY: request_data} def transform_search_response( self, @@ -134,24 +164,158 @@ class TinyfishSearchConfig(BaseSearchConfig): logging_obj: LiteLLMLoggingObj | None, **kwargs: object, ) -> SearchResponse: - raw_json: object = raw_response.json() # any-ok: httpx Response.json() -> Any - parsed = _TinyfishApiResponse.model_validate(raw_json) + """ + Transform a TinyFish response to LiteLLM's unified ``SearchResponse``. - max_results_str: str = "20" - if raw_response.request: - raw_param: object = raw_response.request.url.params.get( # any-ok: httpx QueryParams.get() -> Any - "max_results", "20" + Mappings (per-result): + - ``title`` → ``SearchResult.title`` (defaults to ``""`` if missing/null) + - ``url`` → ``SearchResult.url`` (defaults to ``""``) + - ``snippet`` → ``SearchResult.snippet`` (defaults to ``""``) + - all other per-result fields (``position``, ``site_name``, + ``thumbnail_url``, ``fetch``, ``fetch_error``, ...) ride through as + extras on ``SearchResult`` via its ``extra="allow"`` config. + + Top-level ``parameter_warnings`` (see ML-2085) is read when present and + each entry is re-fired via ``verbose_logger.warning``. Absent or + malformed entries are silently skipped — never throws. + + Error paths routed through ``self._wrap_error`` for uniform + ``"TinyFish Search: . See for details."`` wrapping: + - non-2xx HTTP status (caught here because ``AsyncHTTPHandler.get`` + does not call ``raise_for_status``) + - 200 with non-JSON body + - 200 with valid JSON whose shape doesn't satisfy ``SearchResponse`` + + Returns: + ``SearchResponse`` truncated to ``self._caller_max_results`` (or + ``_TINYFISH_RESULT_CAP`` when the caller didn't set ``max_results``). + """ + # AsyncHTTPHandler.get does not call raise_for_status, so non-2xx + # responses arrive here looking successful. Dispatch through + # get_error_class so callers see a uniform attributed error. + if not (200 <= raw_response.status_code < 300): + raise self._wrap_error( + error_message=raw_response.text, + status_code=raw_response.status_code, + headers=dict(raw_response.headers), ) - max_results_str = str(raw_param) - max_results: int = min(int(max_results_str), 20) - results = [ - SearchResult(title=item.title, url=item.url, snippet=item.snippet) for item in parsed.results[:max_results] - ] + try: + raw_json: object = raw_response.json() # any-ok: httpx Response.json() -> Any + except json.JSONDecodeError: + raise self._wrap_error( + error_message=f"Expected JSON response, got: {raw_response.text[:200]}", + status_code=raw_response.status_code, + headers=dict(raw_response.headers), + ) - return SearchResponse(results=results, object="search") + _default_missing_result_fields(raw_json) + + try: + parsed = SearchResponse.model_validate(raw_json) + except ValidationError as e: + raise self._wrap_error( + error_message=(f"Response shape does not match LiteLLM's SearchResponse schema: {e}"), + status_code=raw_response.status_code, + headers=dict(raw_response.headers), + ) + + _emit_parameter_warnings(parsed) + + max_results = self._caller_max_results or _TINYFISH_RESULT_CAP + return SearchResponse(results=list(parsed.results[:max_results])) + + def _wrap_error( + self, + error_message: str, + status_code: int, + headers: dict[str, str], + ) -> Exception: + """ + Build an attributed ``BaseLLMException`` from a TinyFish error body. + + Used only at the call sites we control inside + ``transform_search_response`` (non-2xx, JSONDecodeError, ValidationError). + Not an override of ``BaseSearchConfig.get_error_class``: that path is + left to inherit from the base so it auto-picks-up any future LiteLLM + improvements. Trade-off: network failures (routed through LiteLLM + core's ``_handle_error`` → ``BaseSearchConfig.get_error_class``) won't + carry the ``TinyFish Search:`` prefix — the bare error already names + the host in the URL, so attribution is implicit there. + """ + # ux-labs frontend wraps every error body as {"error": {"code", "message", "details"?}}. + # Best-effort unwrap to surface the inner message; fall back to the raw body + # for non-ux-labs responses (CDN HTML pages, other JSON envelopes, plain text). + inner_message = error_message + try: + body: object = json.loads(error_message) # any-ok: json.loads -> Any + if isinstance(body, dict): + error_obj: object = body.get("error") # any-ok: untyped dict + if isinstance(error_obj, dict): + candidate: object = error_obj.get("message") # any-ok: untyped dict + if isinstance(candidate, str) and candidate: + inner_message = candidate + except (json.JSONDecodeError, TypeError): + pass + + return BaseLLMException( + status_code=status_code, + message=f"TinyFish Search: {inner_message}. See {_TINYFISH_DOCS_URL} for details.", + headers=headers, + ) def _append_domain_filters(query: str, domains: list[str]) -> str: domain_clauses = " OR ".join(f"site:{d}" for d in domains) return f"({query}) ({domain_clauses})" + + +def _default_missing_result_fields(raw_json: object) -> None: + """Default missing/null title/url/snippet to "" on each result item in place. + + SearchResult requires these three fields; a degraded TinyFish result flows + through with empty strings instead of failing the whole call. + """ + if not isinstance(raw_json, dict): + return + results_in = raw_json.get("results") + if not isinstance(results_in, list): + return + for item in results_in: + if not isinstance(item, dict): + continue + for field in ("title", "url", "snippet"): + if not isinstance(item.get(field), str): + item[field] = "" + + +def _emit_parameter_warnings(parsed: SearchResponse) -> None: + """Re-fire TinyFish-side ``parameter_warnings`` (see ML-2085) as warnings. + + Defensive: skip silently on any shape we don't recognize so a malformed + entry (or an early/partial rollout of the field) never throws. + Schema per entry: ``{type, parameter, message, docs_url?}``. + """ + warnings_field: object = ( + getattr(parsed, "parameter_warnings", None) # any-ok: extras=allow field + ) + if not isinstance(warnings_field, list): + return + for entry in warnings_field: + if not isinstance(entry, dict): + continue + warning_type: object = entry.get("type") # any-ok: untyped dict + parameter: object = entry.get("parameter") # any-ok: untyped dict + message: object = entry.get("message") # any-ok: untyped dict + if not isinstance(warning_type, str) or not warning_type: + continue + if not isinstance(parameter, str) or not parameter: + continue + if not isinstance(message, str) or not message: + continue + verbose_logger.warning( + "TinyFish Search parameter_warning (%s) `%s`: %s", + warning_type, + parameter, + message, + ) diff --git a/litellm/llms/vertex_ai/audio_transcription/transformation.py b/litellm/llms/vertex_ai/audio_transcription/transformation.py new file mode 100644 index 00000000000..03769bf2601 --- /dev/null +++ b/litellm/llms/vertex_ai/audio_transcription/transformation.py @@ -0,0 +1,194 @@ +import base64 + +from httpx import Headers, Response + +import litellm +from litellm.exceptions import UnsupportedParamsError +from litellm.litellm_core_utils.audio_utils.utils import ( + normalize_transcription_language_to_bcp47, + 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.llms.vertex_ai.common_utils import VertexAIError, validate_vertex_location +from litellm.llms.vertex_ai.vertex_llm_base import VertexBase +from litellm.types.llms.openai import ( + AllMessageValues, + OpenAIAudioTranscriptionOptionalParams, +) +from litellm.types.llms.vertex_ai_speech_to_text import ( + VertexSpeechToTextAutoDecodingConfig, + VertexSpeechToTextRecognitionConfig, + VertexSpeechToTextRecognitionFeatures, + VertexSpeechToTextRecognizeRequest, + VertexSpeechToTextRecognizeResponse, +) +from litellm.types.utils import FileTypes, TranscriptionResponse + +DEFAULT_SPEECH_TO_TEXT_LOCATION = "us" +AUTO_LANGUAGE_CODE = "auto" +SUPPORTED_RESPONSE_FORMATS = ("json", "text") +_URL_UNSAFE_PROJECT_CHARS = ("/", "?", "#", "\\", ":", " ", "\t", "\n", "\r") + + +class VertexAIAudioTranscriptionConfig(BaseAudioTranscriptionConfig, VertexBase): + def __init__(self) -> None: + BaseAudioTranscriptionConfig.__init__(self) + VertexBase.__init__(self) + + def get_supported_openai_params(self, model: str) -> list[OpenAIAudioTranscriptionOptionalParams]: + return ["language", "response_format"] + + 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 = { + **optional_params, + **{k: v for k, v in non_default_params.items() if k in supported_params}, + } + response_format = mapped.get("response_format") + if response_format is None or response_format in SUPPORTED_RESPONSE_FORMATS: + return mapped + if drop_params or litellm.drop_params: + return {k: v for k, v in mapped.items() if k != "response_format"} + raise UnsupportedParamsError( + status_code=400, + message=( + f"Google Speech-to-Text does not support response_format={response_format!r}. " + f"Supported values: {', '.join(SUPPORTED_RESPONSE_FORMATS)}. " + "To drop unsupported openai params from the call, set `litellm.drop_params = True`" + ), + ) + + def get_error_class(self, error_message: str, status_code: int, headers: dict | Headers) -> BaseLLMException: + return VertexAIError(status_code=status_code, message=error_message, headers=headers) + + def validate_environment( + self, + headers: dict, + model: str, + messages: list[AllMessageValues], + optional_params: dict, + litellm_params: dict, + api_key: str | None = None, + api_base: str | None = None, + ) -> dict: + access_token, project_id = self._ensure_access_token( + credentials=self.safe_get_vertex_ai_credentials(litellm_params), + project_id=self.safe_get_vertex_ai_project(litellm_params), + custom_llm_provider="vertex_ai", + ) + return { + **headers, + "Authorization": f"Bearer {access_token}", + "x-goog-user-project": project_id, + "Content-Type": "application/json", + } + + def get_complete_url( + self, + api_base: str | None, + api_key: str | None, + model: str, + optional_params: dict, + litellm_params: dict, + stream: bool | None = None, + ) -> str: + location = self._validate_location(self.safe_get_vertex_ai_location(litellm_params)) + project_id = self._validate_project_id( + self.safe_get_vertex_ai_project(litellm_params) or self._resolve_project_id_from_credentials(litellm_params) + ) + host = "speech.googleapis.com" if location == "global" else f"{location}-speech.googleapis.com" + base_url = (api_base or f"https://{host}").rstrip("/") + return f"{base_url}/v2/projects/{project_id}/locations/{location}/recognizers/_:recognize" + + @staticmethod + def _validate_location(location: str | None) -> str: + try: + return validate_vertex_location(location or DEFAULT_SPEECH_TO_TEXT_LOCATION) + except ValueError as e: + raise VertexAIError(status_code=400, message=str(e)) from e + + @staticmethod + def _validate_project_id(project_id: str) -> str: + if not project_id or ".." in project_id or any(c in project_id for c in _URL_UNSAFE_PROJECT_CHARS): + raise VertexAIError(status_code=400, message=f"Invalid vertex_project format: {project_id!r}") + return project_id + + def _resolve_project_id_from_credentials(self, litellm_params: dict) -> str: + _, project_id = self._ensure_access_token( + credentials=self.safe_get_vertex_ai_credentials(litellm_params), + project_id=None, + custom_llm_provider="vertex_ai", + ) + return project_id + + 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) + language = optional_params.get("language") + language_codes = ( + [normalize_transcription_language_to_bcp47(language)] + if isinstance(language, str) and language + else [AUTO_LANGUAGE_CODE] + ) + request_body = VertexSpeechToTextRecognizeRequest( + config=VertexSpeechToTextRecognitionConfig( + model=model.removeprefix("vertex_ai/"), + languageCodes=language_codes, + features=VertexSpeechToTextRecognitionFeatures(enableAutomaticPunctuation=True), + autoDecodingConfig=VertexSpeechToTextAutoDecodingConfig(), + ), + content=base64.b64encode(processed_audio.file_content).decode("utf-8"), + ) + return AudioTranscriptionRequestData(data=dict(request_body)) + + def transform_audio_transcription_response( + self, + raw_response: Response, + ) -> TranscriptionResponse: + try: + response_json = raw_response.json() + except ValueError: + raise VertexAIError( + status_code=raw_response.status_code, + message=f"Received non-JSON response from Google Speech-to-Text: {raw_response.text}", + ) + parsed = VertexSpeechToTextRecognizeResponse.model_validate(response_json) + transcripts = tuple( + result.alternatives[0].transcript + for result in parsed.results + if result.alternatives and result.alternatives[0].transcript + ) + response = TranscriptionResponse(text=" ".join(transcripts)) + response["task"] = "transcribe" + detected_language = next((result.languageCode for result in parsed.results if result.languageCode), None) + if detected_language is not None: + response["language"] = detected_language + billed_duration = _parse_duration_seconds(parsed.metadata.totalBilledDuration if parsed.metadata else None) + if billed_duration is not None: + response["duration"] = billed_duration + response._hidden_params = response_json + return response + + +def _parse_duration_seconds(duration: str | None) -> float | None: + if duration is None or not duration.endswith("s"): + return None + try: + return float(duration[:-1]) + except ValueError: + return None diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 36522dfe396..7dcb4dcf2e8 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -311,6 +311,28 @@ def get_vertex_base_model_name(model: str) -> str: return model +def validate_vertex_location(vertex_location: Optional[str]) -> str: + """ + Validate a Vertex AI location before interpolating it into a request host or + URL path. + + ``vertex_location`` is client-controllable on the proxy (it flows in from the + request body), so it must never be trusted verbatim in a URL or an attacker + could point the host at their own server and exfiltrate the admin's Google + access token. Allow the special ``global`` control plane and otherwise require + a lowercase alphanumeric-plus-hyphen token (e.g. ``us``, ``us-central1``, + ``eu``), which rejects host injection like ``attacker.example/`` or + ``evil.com#``. + """ + if vertex_location == "global": + return vertex_location + 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") + return vertex_location + + def get_vertex_base_url( vertex_location: Optional[str], ) -> str: @@ -321,15 +343,12 @@ def get_vertex_base_url( - 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": + validated_location = validate_vertex_location(vertex_location) + if validated_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" + if "-" not in validated_location: + return f"https://aiplatform.{validated_location}.rep.googleapis.com" + return f"https://{validated_location}-aiplatform.googleapis.com" def _get_embedding_url( diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 678877c0721..8c4bb1aa0c5 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -998,6 +998,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): response_modalities.append("IMAGE") elif modality == "audio": response_modalities.append("AUDIO") + elif modality == "video": + response_modalities.append("VIDEO") else: response_modalities.append("MODALITY_UNSPECIFIED") return response_modalities @@ -2315,17 +2317,15 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): # Store thoughtSignatures in provider_specific_fields if thought_signatures is not None: - if "provider_specific_fields" not in chat_completion_message: - chat_completion_message["provider_specific_fields"] = {} - chat_completion_message["provider_specific_fields"]["thought_signatures"] = thought_signatures # type: ignore + thought_signature_fields = chat_completion_message.get("provider_specific_fields") or {} + thought_signature_fields["thought_signatures"] = thought_signatures + chat_completion_message["provider_specific_fields"] = thought_signature_fields # Store server-side tool invocations in provider_specific_fields if server_side_tool_invocations is not None: - if "provider_specific_fields" not in chat_completion_message: - chat_completion_message["provider_specific_fields"] = {} - chat_completion_message["provider_specific_fields"]["server_side_tool_invocations"] = ( - server_side_tool_invocations # type: ignore - ) + tool_invocation_fields = chat_completion_message.get("provider_specific_fields") or {} + tool_invocation_fields["server_side_tool_invocations"] = server_side_tool_invocations + chat_completion_message["provider_specific_fields"] = tool_invocation_fields if isinstance(model_response, ModelResponseStream): choice = VertexGeminiConfig._create_streaming_choice( diff --git a/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py index 4bca3e8f71d..572725ac789 100644 --- a/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py @@ -1,5 +1,5 @@ import os -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Optional from litellm._logging import verbose_logger @@ -175,7 +175,7 @@ class VertexAIGeminiImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): self, headers: dict, model: str, - messages: List[AllMessageValues], + messages: list[AllMessageValues], optional_params: dict, litellm_params: dict, api_key: Optional[str] = None, @@ -217,10 +217,10 @@ class VertexAIGeminiImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): contents = [{"role": "user", "parts": [{"text": prompt}]}] # Prepare generation config - generation_config: Dict[str, Any] = {"responseModalities": ["IMAGE"]} + generation_config: dict[str, Any] = {"responseModalities": ["IMAGE"]} # Seed from user-supplied imageConfig dict; flat params are overlaid for backward compat. - image_config: Dict[str, Any] = dict(optional_params.get("imageConfig") or {}) + image_config: dict[str, Any] = dict(optional_params.get("imageConfig") or {}) if "aspectRatio" in optional_params: image_config["aspectRatio"] = optional_params["aspectRatio"] @@ -241,7 +241,7 @@ class VertexAIGeminiImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): elif "n" in optional_params: generation_config["candidateCount"] = optional_params["n"] - request_body: Dict[str, Any] = { + request_body: dict[str, Any] = { "contents": contents, "generationConfig": generation_config, } diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py index 8566496bf9c..de72795cabc 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py @@ -17,6 +17,10 @@ from ..output_params_utils import sanitize_vertex_anthropic_output_params class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, VertexBase): + @property + def custom_llm_provider(self) -> Optional[str]: + return "vertex_ai" + def should_strip_billing_metadata(self) -> bool: return True diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py index 280cc1c888a..b87d05ab1fd 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/output_params_utils.py @@ -26,7 +26,7 @@ def _model_accepts_output_config_effort(model: str) -> bool: """ from litellm.llms.anthropic.chat.transformation import AnthropicConfig - return AnthropicConfig._model_supports_effort_param(model) + return AnthropicConfig._model_supports_effort_param(model, "vertex_ai") def sanitize_vertex_anthropic_output_params(data: dict, model: str) -> None: diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py index c8d91be359b..8fcefb04b34 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py @@ -112,6 +112,7 @@ class VertexAIAnthropicConfig(AnthropicConfig): prompt_caching_set=self.is_cache_control_set(messages), file_id_used=self.is_file_id_used(messages), mcp_server_used=self.is_mcp_server_used(optional_params.get("mcp_servers")), + custom_llm_provider="vertex_ai", ) beta_set = set(auto_betas) diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index d57d7bf17df..788261ac1fe 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -9,6 +9,7 @@ import json import os import threading from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple +from urllib.parse import urlparse import litellm from litellm._logging import verbose_logger @@ -315,6 +316,9 @@ class VertexBase: api_base=api_base, ) + if partner == VertexPartnerProvider.llama: + return default_api_base + if len(default_api_base.split(":")) > 1: endpoint = default_api_base.split(":")[-1] else: @@ -615,7 +619,8 @@ class VertexBase: Handles custom api_base for: 1. Gemini (Google AI Studio) - constructs /models/{model}:{endpoint} - 2. Vertex AI with standard proxies - constructs {api_base}:{endpoint} + 2. Vertex AI with standard proxies - constructs {api_base}:{endpoint}; + if api_base has no path (bare host), grafts the default vertex URL path onto it 3. Vertex AI with PSC endpoints - constructs full path structure {api_base}/v1/projects/{project}/locations/{location}/endpoints/{model}:{endpoint} (only when use_psc_endpoint_format=True) @@ -660,8 +665,9 @@ class VertexBase: model_for_url, endpoint, ) + elif urlparse(api_base).path in ("", "/"): + url = api_base.rstrip("/") + urlparse(url).path else: - # Fallback to simple format if we don't have all parameters url = "{}:{}".format(api_base, endpoint) if stream is True: url = url + "?alt=sse" diff --git a/litellm/main.py b/litellm/main.py index 567930a4999..6fd68921fb0 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -92,7 +92,10 @@ from litellm.litellm_core_utils.completion_timeout import CompletionTimeout from litellm.litellm_core_utils.request_timeout_resolver import ( get_configured_request_timeout, ) -from litellm.litellm_core_utils.get_litellm_params import OPTIONAL_KWARGS_KEYS +from litellm.litellm_core_utils.get_litellm_params import ( + AWS_CREDENTIAL_KWARGS_KEYS, + OPTIONAL_KWARGS_KEYS, +) from litellm.litellm_core_utils.dd_tracing import tracer from litellm.litellm_core_utils.get_provider_specific_headers import ( ProviderSpecificHeaderUtils, @@ -582,6 +585,7 @@ async def acompletion( "api_key": api_key, "model_list": model_list, "reasoning_effort": reasoning_effort, + "verbosity": verbosity, "safety_identifier": safety_identifier, "service_tier": service_tier, "extra_headers": extra_headers, @@ -1081,6 +1085,54 @@ def _build_custom_pricing_entry( return entry +def _get_router_deployment_id(kwargs: dict) -> Optional[str]: + for metadata_key in ("litellm_metadata", "metadata"): + metadata = kwargs.get(metadata_key) or {} + if not isinstance(metadata, dict): + continue + deployment_model_info = metadata.get("model_info") or {} + if not isinstance(deployment_model_info, dict): + continue + deployment_id = deployment_model_info.get("id") + if deployment_id is not None: + return str(deployment_id) + return None + + +def _register_custom_pricing_for_request( + model: str, + custom_llm_provider: str, + kwargs: dict, + model_info: Optional[dict], +) -> None: + """Register per-request custom pricing in litellm.model_cost. + + Router-originated requests (identified by the deployment id the router puts + in metadata) get their full pricing registered under that unique id only; + the shared ``{provider}/{model}`` key receives the entry with pricing fields + stripped, mirroring Router._create_deployment. This keeps one deployment's + pricing overrides (e.g. a zero-cost wildcard) from clobbering built-in + pricing used by sibling deployments of the same backend model. Direct SDK + calls keep the legacy behavior of registering the shared key with pricing. + """ + entry = _build_custom_pricing_entry( + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + model_info=model_info, + ) + shared_key = f"{custom_llm_provider}/{model}" + deployment_id = _get_router_deployment_id(kwargs) + if deployment_id is None: + litellm.register_model({shared_key: entry}) + return + litellm.register_model( + { + deployment_id: entry, + shared_key: CustomPricingLiteLLMParams.strip_custom_pricing_fields(entry), + } + ) + + def _complete_azure(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult: _azure_detection_model = ctx._azure_detection_model acompletion = ctx.acompletion @@ -5107,14 +5159,11 @@ def completion( # type: ignore if ( input_cost_per_token is not None and output_cost_per_token is not None ) or input_cost_per_second is not None: - litellm.register_model( - { - f"{custom_llm_provider}/{model}": _build_custom_pricing_entry( - custom_llm_provider=custom_llm_provider, - kwargs=kwargs, - model_info=model_info, - ) - } + _register_custom_pricing_for_request( + model=model, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + model_info=model_info, ) ### BUILD CUSTOM PROMPT TEMPLATE -- IF GIVEN ### custom_prompt_dict = {} # type: ignore @@ -5193,6 +5242,7 @@ def completion( # type: ignore "parallel_tool_calls": parallel_tool_calls, "messages": messages, "reasoning_effort": reasoning_effort, + "verbosity": verbosity, "thinking": thinking, "web_search_options": web_search_options, "include_server_side_tool_invocations": ( @@ -5275,7 +5325,7 @@ def completion( # type: ignore tpm=kwargs.get("tpm"), rpm=kwargs.get("rpm"), use_xai_oauth=kwargs.get("use_xai_oauth", False), - aws_bedrock_project_id=kwargs.get("aws_bedrock_project_id"), + **{key: kwargs[key] for key in AWS_CREDENTIAL_KWARGS_KEYS if key in kwargs}, ) cast(LiteLLMLoggingObj, logging).update_environment_variables( model=model, @@ -5957,14 +6007,11 @@ def embedding( ### REGISTER CUSTOM MODEL PRICING -- IF GIVEN ### if (input_cost_per_token is not None and output_cost_per_token is not None) or input_cost_per_second is not None: - litellm.register_model( - { - f"{custom_llm_provider}/{model}": _build_custom_pricing_entry( - custom_llm_provider=custom_llm_provider, - kwargs=kwargs, - model_info=kwargs.get("model_info"), - ) - } + _register_custom_pricing_for_request( + model=model, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + model_info=kwargs.get("model_info"), ) litellm_params_dict = get_litellm_params(**kwargs) @@ -8304,26 +8351,6 @@ def stream_chunk_builder_text_completion(chunks: list, messages: Optional[List] finish_reason = chunks[-1]["choices"][0]["finish_reason"] logprobs = chunks[-1]["choices"][0]["logprobs"] - response = { - "id": id, - "object": object, - "created": created, - "model": model, - "system_fingerprint": system_fingerprint, - "choices": [ - { - "text": None, - "index": 0, - "logprobs": logprobs, - "finish_reason": finish_reason, - } - ], - "usage": { - "prompt_tokens": None, - "completion_tokens": None, - "total_tokens": None, - }, - } content_list = [] for chunk in chunks: choices = chunk["choices"] @@ -8335,25 +8362,37 @@ def stream_chunk_builder_text_completion(chunks: list, messages: Optional[List] # Combine the "content" strings into a single string || combine the 'function' strings into a single string combined_content = "".join(content_list) - # Update the "content" field within the response dictionary - response["choices"][0]["text"] = combined_content - - if len(combined_content) > 0: - pass - else: - pass - # # Update usage information if needed try: - response["usage"]["prompt_tokens"] = token_counter(model=model, messages=messages) + prompt_tokens = token_counter(model=model, messages=messages) except Exception: # don't allow this failing to block a complete streaming response from being returned print_verbose("token_counter failed, assuming prompt tokens is 0") - response["usage"]["prompt_tokens"] = 0 - response["usage"]["completion_tokens"] = token_counter( + prompt_tokens = 0 + completion_tokens = token_counter( model=model, text=combined_content, count_response_tokens=True, # count_response_tokens is a Flag to tell token counter this is a response, No need to add extra tokens we do for input messages ) - response["usage"]["total_tokens"] = response["usage"]["prompt_tokens"] + response["usage"]["completion_tokens"] + + response = { + "id": id, + "object": object, + "created": created, + "model": model, + "system_fingerprint": system_fingerprint, + "choices": [ + { + "text": combined_content, + "index": 0, + "logprobs": logprobs, + "finish_reason": finish_reason, + } + ], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + } return TextCompletionResponse(**response) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bf63ef73c22..4988160f19b 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -744,7 +744,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "anthropic.claude-haiku-4-5@20251001": { "cache_creation_input_token_cost": 1.25e-06, @@ -768,7 +769,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_native_streaming": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, @@ -787,8 +789,6 @@ "output_cost_per_token_above_200k_tokens": 3e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, - "cache_creation_input_token_cost_above_1hr": 7.5e-06, - "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.5e-05, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07 }, @@ -813,9 +813,7 @@ "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 3e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, - "cache_read_input_token_cost_above_200k_tokens": 6e-07, - "cache_creation_input_token_cost_above_1hr": 7.5e-06, - "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.5e-05 + "cache_read_input_token_cost_above_200k_tokens": 6e-07 }, "anthropic.claude-3-7-sonnet-20240620-v1:0": { "cache_creation_input_token_cost": 4.5e-06, @@ -991,7 +989,8 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "high" + "bedrock_output_config_effort_ceiling": "high", + "supports_parallel_tool_use_config": true }, "anthropic.claude-opus-4-6-v1": { "supports_adaptive_thinking": true, @@ -1010,7 +1009,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1023,7 +1021,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_max_reasoning_effort": true, - "bedrock_output_config_effort_ceiling": "max" + "bedrock_output_config_effort_ceiling": "max", + "supports_parallel_tool_use_config": true }, "global.anthropic.claude-opus-4-6-v1": { "supports_adaptive_thinking": true, @@ -1042,7 +1041,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1055,7 +1053,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_max_reasoning_effort": true, - "bedrock_output_config_effort_ceiling": "max" + "bedrock_output_config_effort_ceiling": "max", + "supports_parallel_tool_use_config": true }, "us.anthropic.claude-opus-4-6-v1": { "supports_adaptive_thinking": true, @@ -1074,7 +1073,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1087,7 +1085,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_max_reasoning_effort": true, - "bedrock_output_config_effort_ceiling": "max" + "bedrock_output_config_effort_ceiling": "max", + "supports_parallel_tool_use_config": true }, "eu.anthropic.claude-opus-4-6-v1": { "supports_adaptive_thinking": true, @@ -1106,7 +1105,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1119,7 +1117,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_max_reasoning_effort": true, - "bedrock_output_config_effort_ceiling": "max" + "bedrock_output_config_effort_ceiling": "max", + "supports_parallel_tool_use_config": true }, "au.anthropic.claude-opus-4-6-v1": { "supports_adaptive_thinking": true, @@ -1138,7 +1137,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1151,7 +1149,8 @@ "supports_native_structured_output": true, "supports_output_config": true, "supports_max_reasoning_effort": true, - "bedrock_output_config_effort_ceiling": "max" + "bedrock_output_config_effort_ceiling": "max", + "supports_parallel_tool_use_config": true }, "anthropic.claude-opus-4-7": { "bedrock_converse_supports_strict_tools": false, @@ -1171,7 +1170,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1186,7 +1184,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "anthropic.claude-mythos-preview": { "input_cost_per_token": 0, @@ -1221,7 +1220,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1236,7 +1234,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "us.anthropic.claude-opus-4-7": { "bedrock_converse_supports_strict_tools": false, @@ -1256,7 +1255,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1271,7 +1269,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "eu.anthropic.claude-opus-4-7": { "bedrock_converse_supports_strict_tools": false, @@ -1291,7 +1290,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1306,7 +1304,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "au.anthropic.claude-opus-4-7": { "bedrock_converse_supports_strict_tools": false, @@ -1326,7 +1325,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1341,7 +1339,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.25e-05, @@ -1360,6 +1359,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1374,7 +1374,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "global.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.25e-05, @@ -1393,6 +1394,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1407,7 +1409,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "us.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -1426,6 +1429,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1440,7 +1444,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "eu.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -1459,6 +1464,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1473,11 +1479,13 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, @@ -1493,7 +1501,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1508,11 +1515,13 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "global.anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, @@ -1528,7 +1537,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1543,11 +1551,13 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "us.anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, "cache_read_input_token_cost": 5.5e-07, @@ -1563,7 +1573,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1578,11 +1587,13 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "eu.anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, "cache_read_input_token_cost": 5.5e-07, @@ -1598,7 +1609,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1613,11 +1623,13 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "au.anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_1hr": 1.1e-05, "cache_read_input_token_cost": 5.5e-07, @@ -1633,7 +1645,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1648,7 +1659,44 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true + }, + "jp.anthropic.claude-opus-4-8": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_1hr": 1.1e-05, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "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_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "jp.anthropic.claude-opus-4-7": { "bedrock_converse_supports_strict_tools": false, @@ -1680,7 +1728,8 @@ "supports_xhigh_reasoning_effort": true, "supports_native_structured_output": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "supports_parallel_tool_use_config": true }, "anthropic.claude-sonnet-5": { "cache_creation_input_token_cost": 2.5e-06, @@ -1699,6 +1748,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1713,7 +1763,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "global.anthropic.claude-sonnet-5": { "cache_creation_input_token_cost": 2.5e-06, @@ -1732,6 +1783,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1746,7 +1798,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "us.anthropic.claude-sonnet-5": { "cache_creation_input_token_cost": 2.75e-06, @@ -1765,6 +1818,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1779,7 +1833,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "eu.anthropic.claude-sonnet-5": { "cache_creation_input_token_cost": 2.75e-06, @@ -1798,6 +1853,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1812,7 +1868,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "au.anthropic.claude-sonnet-5": { "cache_creation_input_token_cost": 2.75e-06, @@ -1831,6 +1888,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1845,7 +1903,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "jp.anthropic.claude-sonnet-5": { "cache_creation_input_token_cost": 2.75e-06, @@ -1864,6 +1923,7 @@ "search_context_size_medium": 0.01 }, "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1878,7 +1938,8 @@ "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "xhigh" + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true }, "anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -1897,7 +1958,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1909,7 +1969,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_native_structured_output": true, - "supports_output_config": true + "supports_output_config": true, + "supports_parallel_tool_use_config": true }, "global.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -1928,7 +1989,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1940,7 +2000,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_native_structured_output": true, - "supports_output_config": true + "supports_output_config": true, + "supports_parallel_tool_use_config": true }, "us.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -1959,7 +2020,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1971,7 +2031,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_native_structured_output": true, - "supports_output_config": true + "supports_output_config": true, + "supports_parallel_tool_use_config": true }, "eu.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -1990,7 +2051,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -2002,7 +2062,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_native_structured_output": true, - "supports_output_config": true + "supports_output_config": true, + "supports_parallel_tool_use_config": true }, "au.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -2021,7 +2082,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -2033,7 +2093,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_native_structured_output": true, - "supports_output_config": true + "supports_output_config": true, + "supports_parallel_tool_use_config": true }, "jp.anthropic.claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -2052,7 +2113,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -2064,7 +2124,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_native_structured_output": true, - "supports_output_config": true + "supports_output_config": true, + "supports_parallel_tool_use_config": true }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -2126,7 +2187,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "anthropic.claude-v1": { "input_cost_per_token": 8e-06, @@ -2376,7 +2438,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "apac.anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, @@ -2466,7 +2529,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "azure/ada": { "input_cost_per_token": 1e-07, @@ -2575,7 +2639,6 @@ "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -2605,7 +2668,6 @@ "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -2666,7 +2728,6 @@ "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, "cache_read_input_token_cost": 5e-07, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -2764,7 +2825,6 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -5752,6 +5812,76 @@ "supports_tool_choice": true, "supports_vision": true }, + "azure/us/gpt-5.4": { + "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost_priority": 5.5e-07, + "input_cost_per_token": 2.75e-06, + "input_cost_per_token_priority": 5.5e-06, + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_priority": 3.3e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "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_vision": true + }, + "azure/eu/gpt-5.4": { + "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost_priority": 5.5e-07, + "input_cost_per_token": 2.75e-06, + "input_cost_per_token_priority": 5.5e-06, + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_priority": 3.3e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "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_vision": true + }, "azure/gpt-5.4-2026-03-05": { "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, @@ -5793,6 +5923,76 @@ "supports_tool_choice": true, "supports_vision": true }, + "azure/us/gpt-5.4-2026-03-05": { + "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost_priority": 5.5e-07, + "input_cost_per_token": 2.75e-06, + "input_cost_per_token_priority": 5.5e-06, + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_priority": 3.3e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "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_vision": true + }, + "azure/eu/gpt-5.4-2026-03-05": { + "cache_read_input_token_cost": 2.8e-07, + "cache_read_input_token_cost_priority": 5.5e-07, + "input_cost_per_token": 2.75e-06, + "input_cost_per_token_priority": 5.5e-06, + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_priority": 3.3e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "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_vision": true + }, "azure/gpt-5.4-pro": { "cache_read_input_token_cost": 3e-06, "cache_read_input_token_cost_above_272k_tokens": 6e-06, @@ -5863,6 +6063,522 @@ "supports_vision": true, "supports_web_search": true }, + "azure/gpt-5.6": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_above_272k_tokens_priority": 2e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_above_272k_tokens_priority": 9e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/gpt-5.6-sol": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_above_272k_tokens_priority": 2e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_above_272k_tokens_priority": 9e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/gpt-5.6-terra": { + "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "cache_read_input_token_cost_priority": 5e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 1e-06, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_above_272k_tokens": 5e-06, + "input_cost_per_token_priority": 5e-06, + "input_cost_per_token_above_272k_tokens_priority": 1e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_above_272k_tokens": 2.25e-05, + "output_cost_per_token_priority": 3e-05, + "output_cost_per_token_above_272k_tokens_priority": 4.5e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/gpt-5.6-luna": { + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "cache_read_input_token_cost_priority": 2e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 4e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "input_cost_per_token_priority": 2e-06, + "input_cost_per_token_above_272k_tokens_priority": 4e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_272k_tokens": 9e-06, + "output_cost_per_token_priority": 1.2e-05, + "output_cost_per_token_above_272k_tokens_priority": 1.8e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6-sol": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6-terra": { + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, + "cache_read_input_token_cost_priority": 6.875e-07, + "input_cost_per_token": 2.75e-06, + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_priority": 6.875e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_priority": 4.125e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/us/gpt-5.6-luna": { + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2.2e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.1e-06, + "input_cost_per_token_above_272k_tokens": 2.2e-06, + "input_cost_per_token_priority": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "output_cost_per_token_above_272k_tokens": 9.9e-06, + "output_cost_per_token_priority": 1.65e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6-sol": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.375e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6-terra": { + "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_above_272k_tokens": 5.5e-07, + "cache_read_input_token_cost_priority": 6.875e-07, + "input_cost_per_token": 2.75e-06, + "input_cost_per_token_above_272k_tokens": 5.5e-06, + "input_cost_per_token_priority": 6.875e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "output_cost_per_token_above_272k_tokens": 2.475e-05, + "output_cost_per_token_priority": 4.125e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.6-luna": { + "cache_read_input_token_cost": 1.1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2.2e-07, + "cache_read_input_token_cost_priority": 2.75e-07, + "input_cost_per_token": 1.1e-06, + "input_cost_per_token_above_272k_tokens": 2.2e-06, + "input_cost_per_token_priority": 2.75e-06, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "output_cost_per_token_above_272k_tokens": 9.9e-06, + "output_cost_per_token_priority": 1.65e-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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, "azure/gpt-5.5": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, @@ -5908,6 +6624,90 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, + "azure/us/gpt-5.5": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.38e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, + "azure/eu/gpt-5.5": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.38e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "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_vision": true, + "supports_web_search": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": false + }, "azure/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, @@ -5950,6 +6750,84 @@ "supports_vision": true, "supports_web_search": true }, + "azure/us/gpt-5.5-2026-04-23": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.38e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "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_vision": true, + "supports_web_search": true + }, + "azure/eu/gpt-5.5-2026-04-23": { + "cache_read_input_token_cost": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, + "cache_read_input_token_cost_priority": 1.38e-06, + "input_cost_per_token": 5.5e-06, + "input_cost_per_token_above_272k_tokens": 1.1e-05, + "input_cost_per_token_priority": 1.375e-05, + "output_cost_per_token": 3.3e-05, + "output_cost_per_token_above_272k_tokens": 4.95e-05, + "output_cost_per_token_priority": 8.25e-05, + "litellm_provider": "azure", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "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_vision": true, + "supports_web_search": true + }, "azure/gpt-5.5-pro": { "cache_read_input_token_cost": 3e-06, "cache_read_input_token_cost_above_272k_tokens": 6e-06, @@ -9373,17 +10251,16 @@ }, "bedrock/us-east-1/minimax.minimax-m2.5": { "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, "litellm_provider": "bedrock", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "source": "https://aws.amazon.com/bedrock/pricing/", "supports_function_calling": true, - "supports_reasoning": true, "supports_system_messages": true, "supports_tool_choice": true, - "output_cost_per_token": 1.2e-06 + "source": "https://aws.amazon.com/bedrock/pricing/" }, "bedrock/us-east-1/moonshotai.kimi-k2-thinking": { "input_cost_per_token": 6e-07, @@ -9612,7 +10489,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, @@ -9634,7 +10512,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, @@ -9787,7 +10666,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, @@ -9809,7 +10689,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, @@ -9995,17 +10876,16 @@ }, "bedrock/us-west-2/minimax.minimax-m2.5": { "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, "litellm_provider": "bedrock", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "source": "https://aws.amazon.com/bedrock/pricing/", "supports_function_calling": true, - "supports_reasoning": true, "supports_system_messages": true, "supports_tool_choice": true, - "output_cost_per_token": 1.2e-06 + "source": "https://aws.amazon.com/bedrock/pricing/" }, "bedrock/us-west-2/moonshotai.kimi-k2-thinking": { "input_cost_per_token": 6e-07, @@ -10451,6 +11331,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true }, @@ -10482,6 +11363,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true @@ -10544,6 +11426,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, @@ -10573,7 +11456,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_parallel_tool_use_config": true }, "claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, @@ -10598,6 +11482,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true }, @@ -10625,6 +11510,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true }, @@ -10678,6 +11564,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true @@ -10705,6 +11592,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true @@ -10733,6 +11621,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "provider_specific_entry": { @@ -10767,6 +11656,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_tool_choice": true, "supports_vision": true, "provider_specific_entry": { @@ -10801,6 +11691,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, @@ -10837,6 +11728,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, @@ -10907,6 +11799,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_native_structured_output": true, "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, @@ -14645,7 +15538,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, @@ -14859,7 +15753,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "eu.meta.llama3-2-1b-instruct-v1:0": { "input_cost_per_token": 1.3e-07, @@ -17864,6 +18759,49 @@ }, "supports_image_size": false }, + "gemini/gemini-3-pro-image": { + "input_cost_per_image": 0.0011, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "litellm_provider": "gemini", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "image_generation", + "output_cost_per_image": 0.134, + "output_cost_per_image_token": 0.00012, + "output_cost_per_token": 1.2e-05, + "rpm": 1000, + "tpm": 4000000, + "output_cost_per_token_batches": 6e-06, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3-pro-image", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query" + }, "gemini/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, @@ -17895,6 +18833,49 @@ ], "supports_function_calling": false, "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.014, + "search_context_size_medium": 0.014, + "search_context_size_high": 0.014 + }, + "web_search_billing_unit": "per_query" + }, + "gemini/gemini-3.1-flash-image": { + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, + "litellm_provider": "gemini", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "image_generation", + "output_cost_per_image": 0.045, + "output_cost_per_image_token": 6e-05, + "output_cost_per_token": 1.5e-06, + "output_cost_per_token_batches": 7.5e-07, + "rpm": 1000, + "tpm": 4000000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": false, "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, @@ -17936,6 +18917,7 @@ ], "supports_function_calling": false, "supports_prompt_caching": true, + "supports_reasoning": false, "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, @@ -18630,6 +19612,39 @@ }, "web_search_billing_unit": "per_query" }, + "gemini/gemini-omni-flash-preview": { + "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "gemini", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "output_cost_per_video_token": 1.75e-05, + "rpm": 2000, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "video" + ], + "supports_audio_input": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_video_input": true, + "supports_vision": true, + "tpm": 800000 + }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, @@ -18794,6 +19809,37 @@ }, "web_search_billing_unit": "per_query" }, + "gemini-omni-flash-preview": { + "input_cost_per_audio_token": 1.5e-06, + "input_cost_per_token": 1.5e-06, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_reasoning_token": 9e-06, + "output_cost_per_token": 9e-06, + "output_cost_per_video_token": 1.75e-05, + "source": "https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/omni-flash-preview", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text", + "video" + ], + "supports_audio_input": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_video_input": true, + "supports_vision": true + }, "gemini-3.5-flash": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_audio_token": 1e-06, @@ -19222,7 +20268,6 @@ "supported_endpoints": [ "/v1/chat/completions" ], - "supports_adaptive_thinking": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true @@ -20059,7 +21104,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "global.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -20112,7 +21158,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "global.amazon.nova-2-lite-v1:0": { "cache_read_input_token_cost": 7.5e-08, @@ -21956,6 +23003,218 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, + "gpt-5.6": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_creation_input_token_cost_flex": 3.125e-06, + "cache_creation_input_token_cost_priority": 1.25e-05, + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "cache_read_input_token_cost_flex": 2.5e-07, + "cache_read_input_token_cost_priority": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "input_cost_per_token_batches": 2.5e-06, + "input_cost_per_token_flex": 2.5e-06, + "input_cost_per_token_priority": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "output_cost_per_token_batches": 1.5e-05, + "output_cost_per_token_flex": 1.5e-05, + "output_cost_per_token_priority": 6e-05, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, + "gpt-5.6-sol": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_creation_input_token_cost_flex": 3.125e-06, + "cache_creation_input_token_cost_priority": 1.25e-05, + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "cache_read_input_token_cost_flex": 2.5e-07, + "cache_read_input_token_cost_priority": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "input_cost_per_token_batches": 2.5e-06, + "input_cost_per_token_flex": 2.5e-06, + "input_cost_per_token_priority": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "output_cost_per_token_batches": 1.5e-05, + "output_cost_per_token_flex": 1.5e-05, + "output_cost_per_token_priority": 6e-05, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, + "gpt-5.6-terra": { + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_above_272k_tokens": 6.25e-06, + "cache_creation_input_token_cost_flex": 1.5625e-06, + "cache_creation_input_token_cost_priority": 6.25e-06, + "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_above_272k_tokens": 5e-07, + "cache_read_input_token_cost_flex": 1.25e-07, + "cache_read_input_token_cost_priority": 5e-07, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_above_272k_tokens": 5e-06, + "input_cost_per_token_batches": 1.25e-06, + "input_cost_per_token_flex": 1.25e-06, + "input_cost_per_token_priority": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_above_272k_tokens": 2.25e-05, + "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_flex": 7.5e-06, + "output_cost_per_token_priority": 3e-05, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, + "gpt-5.6-luna": { + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-06, + "cache_creation_input_token_cost_flex": 6.25e-07, + "cache_creation_input_token_cost_priority": 2.5e-06, + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_above_272k_tokens": 2e-07, + "cache_read_input_token_cost_flex": 5e-08, + "cache_read_input_token_cost_priority": 2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_above_272k_tokens": 2e-06, + "input_cost_per_token_batches": 5e-07, + "input_cost_per_token_flex": 5e-07, + "input_cost_per_token_priority": 2e-06, + "litellm_provider": "openai", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_272k_tokens": 9e-06, + "output_cost_per_token_batches": 3e-06, + "output_cost_per_token_flex": 3e-06, + "output_cost_per_token_priority": 1.2e-05, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "gpt-5.5": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, @@ -21976,8 +23235,8 @@ "output_cost_per_token_flex": 1.5e-05, "output_cost_per_token_batches": 1.5e-05, "output_cost_per_token_priority": 6e-05, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -22025,8 +23284,8 @@ "output_cost_per_token_flex": 1.5e-05, "output_cost_per_token_batches": 1.5e-05, "output_cost_per_token_priority": 6e-05, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -22070,8 +23329,8 @@ "output_cost_per_token_above_272k_tokens": 0.00027, "output_cost_per_token_flex": 9e-05, "output_cost_per_token_batches": 9e-05, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -22115,8 +23374,8 @@ "output_cost_per_token_above_272k_tokens": 0.00027, "output_cost_per_token_flex": 9e-05, "output_cost_per_token_batches": 9e-05, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -22164,8 +23423,8 @@ "output_cost_per_token_flex": 7.5e-06, "output_cost_per_token_batches": 7.5e-06, "output_cost_per_token_priority": 3e-05, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -22212,8 +23471,8 @@ "output_cost_per_token_flex": 7.5e-06, "output_cost_per_token_batches": 7.5e-06, "output_cost_per_token_priority": 3e-05, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -22253,8 +23512,8 @@ "output_cost_per_token_above_272k_tokens": 0.00027, "output_cost_per_token_flex": 9e-05, "output_cost_per_token_batches": 9e-05, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -22297,8 +23556,8 @@ "output_cost_per_token_above_272k_tokens": 0.00027, "output_cost_per_token_flex": 9e-05, "output_cost_per_token_batches": 9e-05, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/responses", "/v1/batch" @@ -22342,8 +23601,8 @@ "output_cost_per_token_flex": 2.25e-06, "output_cost_per_token_batches": 2.25e-06, "output_cost_per_token_priority": 9e-06, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -22388,8 +23647,8 @@ "output_cost_per_token_flex": 2.25e-06, "output_cost_per_token_batches": 2.25e-06, "output_cost_per_token_priority": 9e-06, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -22431,8 +23690,8 @@ "output_cost_per_token": 1.25e-06, "output_cost_per_token_flex": 6.25e-07, "output_cost_per_token_batches": 6.25e-07, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -22474,8 +23733,8 @@ "output_cost_per_token": 1.25e-06, "output_cost_per_token_flex": 6.25e-07, "output_cost_per_token_batches": 6.25e-07, - "regional_processing_uplift_multiplier_eu": 1.10, - "regional_processing_uplift_multiplier_us": 1.10, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -23186,6 +24445,76 @@ "supports_system_messages": true, "supports_tool_choice": true }, + "gpt-realtime-2.1": { + "cache_creation_input_audio_token_cost": 4e-07, + "cache_read_input_audio_token_cost": 4e-07, + "cache_read_input_token_cost": 4e-07, + "input_cost_per_audio_token": 3.2e-05, + "input_cost_per_image": 5e-06, + "input_cost_per_token": 4e-06, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_audio_token": 6.4e-05, + "output_cost_per_token": 2.4e-05, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-realtime-2.1-mini": { + "cache_creation_input_audio_token_cost": 3e-07, + "cache_read_input_audio_token_cost": 3e-07, + "cache_read_input_token_cost": 6e-08, + "input_cost_per_audio_token": 1e-05, + "input_cost_per_image": 8e-07, + "input_cost_per_token": 6e-07, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2e-05, + "output_cost_per_token": 2.4e-06, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, "gpt-realtime-mini": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_audio_token_cost": 3e-07, @@ -24181,7 +25510,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "jp.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, @@ -24204,7 +25534,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "crusoe/deepseek-ai/DeepSeek-R1-0528": { "input_cost_per_token": 3e-06, @@ -24900,6 +26231,42 @@ "supports_function_calling": true, "supports_tool_choice": false }, + "meta/muse-spark-1.1": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "meta", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://dev.meta.ai/docs/getting-started/pricing-rate-limits", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": 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_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "meta_llama/Llama-3.3-70B-Instruct": { "litellm_provider": "meta_llama", "max_input_tokens": 128000, @@ -24992,14 +26359,13 @@ }, "minimax.minimax-m2.5": { "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 1.2e-06, "supports_function_calling": true, - "supports_reasoning": true, "supports_system_messages": true, "supports_tool_choice": true, "source": "https://aws.amazon.com/bedrock/pricing/" @@ -28455,7 +29821,6 @@ "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_200k_tokens": 2.25e-05, "source": "https://openrouter.ai/anthropic/claude-sonnet-4.6", - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -28495,7 +29860,6 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.5e-05, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -28557,7 +29921,6 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.5e-05, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -30480,7 +31843,6 @@ "supports_adaptive_thinking": true, "litellm_provider": "perplexity", "mode": "responses", - "supports_adaptive_thinking": true, "supports_web_search": true, "supports_reasoning": false, "supports_function_calling": true, @@ -30490,7 +31852,6 @@ "supports_adaptive_thinking": true, "litellm_provider": "perplexity", "mode": "responses", - "supports_adaptive_thinking": true, "supports_web_search": true, "supports_reasoning": false, "supports_function_calling": true, @@ -31406,7 +32767,7 @@ "supports_tool_choice": true }, "sambanova/Meta-Llama-3.2-1B-Instruct": { - "deprecation_date": "2025-06-25", + "deprecation_date": "2025-06-25", "input_cost_per_token": 4e-08, "litellm_provider": "sambanova", "max_input_tokens": 16384, @@ -31537,15 +32898,15 @@ "supports_vision": true, "source": "https://cloud.sambanova.ai/plans/pricing" }, - "snowflake/claude-3-5-sonnet": { + "snowflake/claude-3-5-sonnet": { "litellm_provider": "snowflake", "max_input_tokens": 200000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 0.0000003, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, "supports_computer_use": true, "supports_function_calling": true, "supports_vision": true, @@ -31553,14 +32914,14 @@ "supports_system_messages": true, "supports_response_schema": true }, - "snowflake/deepseek-r1": { + "snowflake/deepseek-r1": { "litellm_provider": "snowflake", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "input_cost_per_token": 0.00000135, - "output_cost_per_token": 0.0000054, + "input_cost_per_token": 1.35e-06, + "output_cost_per_token": 5.4e-06, "supports_reasoning": true, "supports_system_messages": true }, @@ -31619,8 +32980,8 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "input_cost_per_token": 0.0000012, - "output_cost_per_token": 0.0000012, + "input_cost_per_token": 1.2e-06, + "output_cost_per_token": 1.2e-06, "supports_function_calling": true, "supports_system_messages": true }, @@ -31630,8 +32991,8 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "input_cost_per_token": 0.00000072, - "output_cost_per_token": 0.00000072, + "input_cost_per_token": 7.2e-07, + "output_cost_per_token": 7.2e-07, "supports_function_calling": true, "supports_system_messages": true }, @@ -31641,8 +33002,8 @@ "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "input_cost_per_token": 0.00000024, - "output_cost_per_token": 0.00000024, + "input_cost_per_token": 2.4e-07, + "output_cost_per_token": 2.4e-07, "supports_system_messages": true }, "snowflake/llama3.2-1b": { @@ -31659,17 +33020,17 @@ "max_tokens": 8192, "mode": "chat" }, - "snowflake/llama3.3-70b": { + "snowflake/llama3.3-70b": { "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, - "input_cost_per_token": 0.00000072, - "output_cost_per_token": 0.00000072, + "input_cost_per_token": 7.2e-07, + "output_cost_per_token": 7.2e-07, "litellm_provider": "snowflake", "mode": "chat", "supports_function_calling": true, "supports_system_messages": true - }, + }, "snowflake/mistral-7b": { "litellm_provider": "snowflake", "max_input_tokens": 32000, @@ -31684,14 +33045,14 @@ "max_tokens": 8192, "mode": "chat" }, - "snowflake/mistral-large2": { + "snowflake/mistral-large2": { "litellm_provider": "snowflake", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000006, + "input_cost_per_token": 2e-06, + "output_cost_per_token": 6e-06, "supports_function_calling": true, "supports_system_messages": true, "supports_response_schema": true @@ -31731,17 +33092,17 @@ "max_tokens": 8192, "mode": "chat" }, - "snowflake/snowflake-llama-3.3-70b": { + "snowflake/snowflake-llama-3.3-70b": { "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, - "input_cost_per_token": 0.00000072, - "output_cost_per_token": 0.00000072, + "input_cost_per_token": 7.2e-07, + "output_cost_per_token": 7.2e-07, "litellm_provider": "snowflake", "mode": "chat", "supports_function_calling": true, "supports_system_messages": true - }, + }, "stability/sd3": { "litellm_provider": "stability", "mode": "image_generation", @@ -32825,7 +34186,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "us.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, @@ -32984,7 +34346,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, @@ -32993,7 +34356,7 @@ "input_cost_per_token": 3.6e-06, "input_cost_per_token_above_200k_tokens": 7.2e-06, "output_cost_per_token_above_200k_tokens": 2.7e-05, - "cache_creation_input_token_cost_above_200k_tokens": 9.0e-06, + "cache_creation_input_token_cost_above_200k_tokens": 9e-06, "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.44e-05, "cache_read_input_token_cost_above_200k_tokens": 7.2e-07, "litellm_provider": "bedrock_converse", @@ -33011,7 +34374,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "au.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, @@ -33033,7 +34397,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_parallel_tool_use_config": true }, "us.anthropic.claude-opus-4-20250514-v1:0": { "cache_creation_input_token_cost": 1.875e-05, @@ -33087,7 +34452,8 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "high" + "bedrock_output_config_effort_ceiling": "high", + "supports_parallel_tool_use_config": true }, "global.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 6.25e-06, @@ -33116,7 +34482,8 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "high" + "bedrock_output_config_effort_ceiling": "high", + "supports_parallel_tool_use_config": true }, "eu.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 6.25e-06, @@ -33144,7 +34511,8 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_output_config": true, - "bedrock_output_config_effort_ceiling": "high" + "bedrock_output_config_effort_ceiling": "high", + "supports_parallel_tool_use_config": true }, "us.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -33736,7 +35104,6 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 2.5e-05, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -34638,6 +36005,19 @@ "/v1/audio/speech" ] }, + "vertex_ai/chirp_3": { + "input_cost_per_second": 0.00026667, + "litellm_provider": "vertex_ai", + "metadata": { + "calculation": "$0.016/60 seconds = $0.00026667 per second", + "original_pricing_per_minute": 0.016 + }, + "mode": "audio_transcription", + "source": "https://cloud.google.com/speech-to-text/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ] + }, "vertex_ai/claude-3-5-haiku": { "input_cost_per_token": 1e-06, "litellm_provider": "vertex_ai-anthropic_models", @@ -34971,7 +36351,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35001,7 +36380,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35031,7 +36409,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35062,7 +36439,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35153,7 +36529,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35184,7 +36559,6 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -35267,7 +36641,6 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -35567,6 +36940,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": false, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -35576,6 +36950,22 @@ "tpm": 8000000, "supports_image_size": false }, + "vertex_ai/gemini-3-pro-image": { + "input_cost_per_image": 0.0011, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "image_generation", + "output_cost_per_image": 0.134, + "output_cost_per_image_token": 0.00012, + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_batches": 6e-06, + "supports_reasoning": false, + "source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image" + }, "vertex_ai/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, @@ -35589,8 +36979,23 @@ "output_cost_per_image_token": 0.00012, "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 6e-06, + "supports_reasoning": false, "source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image" }, + "vertex_ai/gemini-3.1-flash-image": { + "input_cost_per_image": 0.00056, + "input_cost_per_token": 5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "image_generation", + "output_cost_per_image": 0.0672, + "output_cost_per_image_token": 6e-05, + "output_cost_per_token": 3e-06, + "supports_reasoning": false, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models" + }, "vertex_ai/gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, "input_cost_per_token": 5e-07, @@ -35602,6 +37007,7 @@ "output_cost_per_image": 0.0672, "output_cost_per_image_token": 6e-05, "output_cost_per_token": 3e-06, + "supports_reasoning": false, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models" }, "vertex_ai/gemini-3.1-flash-lite-preview": { @@ -37706,6 +39112,48 @@ "supports_vision": true, "supports_web_search": true }, + "xai/grok-4.5": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_200k_tokens": 1e-06, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "xai", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-4.5-latest": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_200k_tokens": 1e-06, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "xai", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "xai/grok-beta": { "input_cost_per_token": 5e-06, "litellm_provider": "xai", @@ -37796,12 +39244,12 @@ }, "zai.glm-5": { "input_cost_per_token": 1e-06, + "output_cost_per_token": 3.2e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3.2e-06, "supports_function_calling": true, "supports_reasoning": true, "supports_system_messages": true, @@ -37822,20 +39270,6 @@ "supports_tool_choice": true, "source": "https://aws.amazon.com/bedrock/pricing/" }, - "zai.glm-5": { - "input_cost_per_token": 1e-06, - "litellm_provider": "bedrock_converse", - "max_input_tokens": 200000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 3.2e-06, - "source": "https://aws.amazon.com/bedrock/pricing/", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "zai/glm-5": { "cache_creation_input_token_cost": 0, "cache_read_input_token_cost": 2e-07, @@ -42734,7 +44168,6 @@ "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, - "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -42768,7 +44201,10 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -42783,7 +44219,10 @@ "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, @@ -42798,7 +44237,9 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "supported_endpoints": ["/v1/chat/completions"], + "supported_endpoints": [ + "/v1/chat/completions" + ], "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, @@ -42812,12 +44253,98 @@ "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "supported_endpoints": ["/v1/chat/completions"], + "supported_endpoints": [ + "/v1/chat/completions" + ], "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, + "bedrock_mantle/openai.gpt-5.6-sol": { + "input_cost_per_token": 5.5e-06, + "cache_creation_input_token_cost": 6.875e-06, + "cache_read_input_token_cost": 5.5e-07, + "output_cost_per_token": 3.3e-05, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/openai.gpt-5.6-terra": { + "input_cost_per_token": 2.75e-06, + "cache_creation_input_token_cost": 3.4375e-06, + "cache_read_input_token_cost": 2.75e-07, + "output_cost_per_token": 1.65e-05, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/openai.gpt-5.6-luna": { + "input_cost_per_token": 1.1e-06, + "cache_creation_input_token_cost": 1.375e-06, + "cache_read_input_token_cost": 1.1e-07, + "output_cost_per_token": 6.6e-06, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, "bedrock_mantle/openai.gpt-5.5": { "input_cost_per_token": 5.5e-06, "cache_read_input_token_cost": 5.5e-07, @@ -42828,9 +44355,16 @@ "max_tokens": 128000, "mode": "responses", "use_openai_responses_path": true, - "supported_endpoints": ["/v1/responses"], - "supported_modalities": ["text", "image"], - "supported_output_modalities": ["text"], + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -42848,9 +44382,16 @@ "max_tokens": 128000, "mode": "responses", "use_openai_responses_path": true, - "supported_endpoints": ["/v1/responses"], - "supported_modalities": ["text", "image"], - "supported_output_modalities": ["text"], + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -42867,7 +44408,10 @@ "max_tokens": 256000, "mode": "chat", "use_openai_responses_path": true, - "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_reasoning": true, @@ -42883,7 +44427,10 @@ "max_tokens": 256000, "mode": "chat", "use_openai_responses_path": true, - "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_reasoning": true, @@ -42899,7 +44446,10 @@ "max_tokens": 128000, "mode": "chat", "use_openai_responses_path": true, - "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_reasoning": true, @@ -43078,20 +44628,6 @@ } ] }, - "zai.glm-5": { - "input_cost_per_token": 1e-06, - "output_cost_per_token": 3.2e-06, - "litellm_provider": "bedrock_converse", - "max_input_tokens": 200000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "source": "https://aws.amazon.com/bedrock/pricing/" - }, "bedrock/us-east-1/zai.glm-5": { "input_cost_per_token": 1e-06, "output_cost_per_token": 3.2e-06, @@ -43120,45 +44656,6 @@ "supports_tool_choice": true, "source": "https://aws.amazon.com/bedrock/pricing/" }, - "minimax.minimax-m2.5": { - "input_cost_per_token": 3e-07, - "output_cost_per_token": 1.2e-06, - "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "supports_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "source": "https://aws.amazon.com/bedrock/pricing/" - }, - "bedrock/us-east-1/minimax.minimax-m2.5": { - "input_cost_per_token": 3e-07, - "output_cost_per_token": 1.2e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 1000000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "supports_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "source": "https://aws.amazon.com/bedrock/pricing/" - }, - "bedrock/us-west-2/minimax.minimax-m2.5": { - "input_cost_per_token": 3e-07, - "output_cost_per_token": 1.2e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 1000000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "supports_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "source": "https://aws.amazon.com/bedrock/pricing/" - }, "bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.5e-06, "cache_creation_input_token_cost_above_1hr": 2.4e-06, @@ -43180,7 +44677,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_native_structured_output": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_parallel_tool_use_config": true }, "bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.5e-06, @@ -43203,364 +44701,368 @@ "supports_tool_choice": true, "supports_vision": true, "supports_native_structured_output": true, - "supports_pdf_input": true + "supports_pdf_input": true, + "supports_parallel_tool_use_config": true + }, + "snowflake/claude-sonnet-4-5": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/claude-sonnet-4-6": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/claude-4-sonnet": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/claude-4-opus": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "cache_read_input_token_cost": 5e-07, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "supports_response_schema": true + }, + "snowflake/claude-haiku-4-5": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 1e-06, + "output_cost_per_token": 5e-06, + "cache_read_input_token_cost": 1e-07, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/claude-3-7-sonnet": { + "max_tokens": 16384, + "max_input_tokens": 200000, + "max_output_tokens": 16384, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "supports_response_schema": true + }, + "snowflake/openai-gpt-4.1": { + "max_tokens": 16384, + "max_input_tokens": 300000, + "max_output_tokens": 16384, + "input_cost_per_token": 2e-06, + "output_cost_per_token": 8e-06, + "cache_read_input_token_cost": 5e-07, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/openai-gpt-5": { + "max_tokens": 16384, + "max_input_tokens": 300000, + "max_output_tokens": 16384, + "input_cost_per_token": 1.25e-06, + "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 1.25e-07, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "supports_response_schema": true + }, + "snowflake/openai-gpt-5-mini": { + "max_tokens": 16384, + "max_input_tokens": 1000000, + "max_output_tokens": 16384, + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/openai-gpt-5-nano": { + "max_tokens": 16384, + "max_input_tokens": 5000000, + "max_output_tokens": 16384, + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 6e-07, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true, + "supports_response_schema": true + }, + "snowflake/llama4-maverick": { + "max_tokens": 16384, + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "input_cost_per_token": 2.4e-07, + "output_cost_per_token": 9.7e-07, + "litellm_provider": "snowflake", + "mode": "chat", + "supports_function_calling": true, + "supports_system_messages": true + }, + "snowflake/snowflake-arctic-embed-l-v2.0": { + "max_tokens": 8192, + "max_input_tokens": 8192, + "input_cost_per_token": 7e-08, + "output_cost_per_token": 0.0, + "litellm_provider": "snowflake", + "mode": "embedding" + }, + "snowflake/snowflake-arctic-embed-m-v2.0": { + "max_tokens": 8192, + "max_input_tokens": 8192, + "input_cost_per_token": 7e-08, + "output_cost_per_token": 0.0, + "litellm_provider": "snowflake", + "mode": "embedding" + }, + "soniox/stt-async-v4": { + "litellm_provider": "soniox", + "max_output_tokens": 8000, + "max_tokens": 8000, + "input_cost_per_second": 0.0, + "output_cost_per_second": 2.77778e-05, + "mode": "audio_transcription", + "source": "https://soniox.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ], + "supports_audio_input": true + }, + "soniox/stt-async-v5": { + "litellm_provider": "soniox", + "max_output_tokens": 8000, + "max_tokens": 8000, + "input_cost_per_second": 0.0, + "output_cost_per_second": 2.77778e-05, + "mode": "audio_transcription", + "source": "https://soniox.com/pricing", + "supported_endpoints": [ + "/v1/audio/transcriptions" + ], + "supports_audio_input": true + }, + "tensormesh/Qwen/Qwen3.5-397B-A17B-FP8": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 6e-07, + "output_cost_per_token": 3.6e-06, + "cache_read_input_token_cost": 0, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 4.5e-07, + "output_cost_per_token": 1.8e-06, + "cache_read_input_token_cost": 0, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/Qwen/Qwen3.6-27B-FP8": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 3.2e-07, + "output_cost_per_token": 3.2e-06, + "cache_read_input_token_cost": 0, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/lukealonso/GLM-5.1-NVFP4-MTP": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "cache_read_input_token_cost": 0, + "max_input_tokens": 202752, + "max_output_tokens": 202752, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/deepseek-ai/DeepSeek-V4-Flash": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 2.8e-07, + "cache_read_input_token_cost": 0, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/moonshotai/Kimi-K2.6": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 9.6e-07, + "output_cost_per_token": 4e-06, + "cache_read_input_token_cost": 0, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/MiniMaxAI/MiniMax-M2.5": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "cache_read_input_token_cost": 0, + "max_input_tokens": 196608, + "max_output_tokens": 196608, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/google/gemma-4-31B-it": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 5.6e-07, + "cache_read_input_token_cost": 0, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/openai/gpt-oss-120b": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 6e-07, + "cache_read_input_token_cost": 0, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" + }, + "tensormesh/openai/gpt-oss-20b": { + "litellm_provider": "tensormesh", + "mode": "chat", + "input_cost_per_token": 7e-08, + "output_cost_per_token": 2.8e-07, + "cache_read_input_token_cost": 0, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_response_schema": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_reasoning": true, + "source": "https://serverless.tensormesh.ai/v1/models/openrouter" }, - "snowflake/claude-sonnet-4-5": { - "max_tokens": 16384, - "max_input_tokens": 200000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 0.0000003, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_vision": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_response_schema": true - }, - "snowflake/claude-sonnet-4-6": { - "max_tokens": 16384, - "max_input_tokens": 200000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 0.0000003, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_vision": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_response_schema": true - }, - "snowflake/claude-4-sonnet": { - "max_tokens": 16384, - "max_input_tokens": 200000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 0.0000003, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_vision": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_response_schema": true - }, - "snowflake/claude-4-opus": { - "max_tokens": 16384, - "max_input_tokens": 200000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025, - "cache_read_input_token_cost": 0.0000005, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_vision": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "supports_response_schema": true - }, - "snowflake/claude-haiku-4-5": { - "max_tokens": 16384, - "max_input_tokens": 200000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000005, - "cache_read_input_token_cost": 0.0000001, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_vision": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_response_schema": true - }, - "snowflake/claude-3-7-sonnet": { - "max_tokens": 16384, - "max_input_tokens": 200000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 0.0000003, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_vision": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "supports_response_schema": true - }, - "snowflake/openai-gpt-4.1": { - "max_tokens": 16384, - "max_input_tokens": 300000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.000002, - "output_cost_per_token": 0.000008, - "cache_read_input_token_cost": 0.0000005, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_vision": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_response_schema": true - }, - "snowflake/openai-gpt-5": { - "max_tokens": 16384, - "max_input_tokens": 300000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.00000125, - "output_cost_per_token": 0.00001, - "cache_read_input_token_cost": 0.000000125, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_vision": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "supports_response_schema": true - }, - "snowflake/openai-gpt-5-mini": { - "max_tokens": 16384, - "max_input_tokens": 1000000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.0000003, - "output_cost_per_token": 0.0000012, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_system_messages": true, - "supports_response_schema": true - }, - "snowflake/openai-gpt-5-nano": { - "max_tokens": 16384, - "max_input_tokens": 5000000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.00000015, - "output_cost_per_token": 0.0000006, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_system_messages": true, - "supports_response_schema": true - }, - "snowflake/llama4-maverick": { - "max_tokens": 16384, - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "input_cost_per_token": 0.00000024, - "output_cost_per_token": 0.00000097, - "litellm_provider": "snowflake", - "mode": "chat", - "supports_function_calling": true, - "supports_system_messages": true - }, - "snowflake/snowflake-arctic-embed-l-v2.0": { - "max_tokens": 8192, - "max_input_tokens": 8192, - "input_cost_per_token": 0.00000007, - "output_cost_per_token": 0.0, - "litellm_provider": "snowflake", - "mode": "embedding" - }, - "snowflake/snowflake-arctic-embed-m-v2.0": { - "max_tokens": 8192, - "max_input_tokens": 8192, - "input_cost_per_token": 0.00000007, - "output_cost_per_token": 0.0, - "litellm_provider": "snowflake", - "mode": "embedding" - }, - "soniox/stt-async-v4": { - "litellm_provider": "soniox", - "max_output_tokens": 8000, - "max_tokens": 8000, - "input_cost_per_second": 0.0, - "output_cost_per_second": 0.0000277778, - "mode": "audio_transcription", - "source": "https://soniox.com/pricing", - "supported_endpoints": ["/v1/audio/transcriptions"], - "supports_audio_input": true - }, - "soniox/stt-async-v5": { - "litellm_provider": "soniox", - "max_output_tokens": 8000, - "max_tokens": 8000, - "input_cost_per_second": 0.0, - "output_cost_per_second": 0.0000277778, - "mode": "audio_transcription", - "source": "https://soniox.com/pricing", - "supported_endpoints": ["/v1/audio/transcriptions"], - "supports_audio_input": true - }, - "tensormesh/Qwen/Qwen3.5-397B-A17B-FP8": { - "litellm_provider": "tensormesh", - "mode": "chat", - "input_cost_per_token": 6e-07, - "output_cost_per_token": 3.6e-06, - "cache_read_input_token_cost": 0, - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "source": "https://serverless.tensormesh.ai/v1/models/openrouter" - }, - "tensormesh/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { - "litellm_provider": "tensormesh", - "mode": "chat", - "input_cost_per_token": 4.5e-07, - "output_cost_per_token": 1.8e-06, - "cache_read_input_token_cost": 0, - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "source": "https://serverless.tensormesh.ai/v1/models/openrouter" - }, - "tensormesh/Qwen/Qwen3.6-27B-FP8": { - "litellm_provider": "tensormesh", - "mode": "chat", - "input_cost_per_token": 3.2e-07, - "output_cost_per_token": 3.2e-06, - "cache_read_input_token_cost": 0, - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "source": "https://serverless.tensormesh.ai/v1/models/openrouter" - }, - "tensormesh/lukealonso/GLM-5.1-NVFP4-MTP": { - "litellm_provider": "tensormesh", - "mode": "chat", - "input_cost_per_token": 1.4e-06, - "output_cost_per_token": 4.4e-06, - "cache_read_input_token_cost": 0, - "max_input_tokens": 202752, - "max_output_tokens": 202752, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "source": "https://serverless.tensormesh.ai/v1/models/openrouter" - }, - "tensormesh/deepseek-ai/DeepSeek-V4-Flash": { - "litellm_provider": "tensormesh", - "mode": "chat", - "input_cost_per_token": 1.4e-07, - "output_cost_per_token": 2.8e-07, - "cache_read_input_token_cost": 0, - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "source": "https://serverless.tensormesh.ai/v1/models/openrouter" - }, - "tensormesh/moonshotai/Kimi-K2.6": { - "litellm_provider": "tensormesh", - "mode": "chat", - "input_cost_per_token": 9.6e-07, - "output_cost_per_token": 4e-06, - "cache_read_input_token_cost": 0, - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "source": "https://serverless.tensormesh.ai/v1/models/openrouter" - }, - "tensormesh/MiniMaxAI/MiniMax-M2.5": { - "litellm_provider": "tensormesh", - "mode": "chat", - "input_cost_per_token": 3e-07, - "output_cost_per_token": 1.2e-06, - "cache_read_input_token_cost": 0, - "max_input_tokens": 196608, - "max_output_tokens": 196608, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "source": "https://serverless.tensormesh.ai/v1/models/openrouter" - }, - "tensormesh/google/gemma-4-31B-it": { - "litellm_provider": "tensormesh", - "mode": "chat", - "input_cost_per_token": 1.4e-07, - "output_cost_per_token": 5.6e-07, - "cache_read_input_token_cost": 0, - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "source": "https://serverless.tensormesh.ai/v1/models/openrouter" - }, - "tensormesh/openai/gpt-oss-120b": { - "litellm_provider": "tensormesh", - "mode": "chat", - "input_cost_per_token": 1.5e-07, - "output_cost_per_token": 6e-07, - "cache_read_input_token_cost": 0, - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "source": "https://serverless.tensormesh.ai/v1/models/openrouter" - }, - "tensormesh/openai/gpt-oss-20b": { - "litellm_provider": "tensormesh", - "mode": "chat", - "input_cost_per_token": 7e-08, - "output_cost_per_token": 2.8e-07, - "cache_read_input_token_cost": 0, - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_reasoning": true, - "source": "https://serverless.tensormesh.ai/v1/models/openrouter" - } - , "deepseek-v4-flash": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 2.8e-09, @@ -43695,12 +45197,64 @@ "supports_tool_choice": true, "supports_vision": false }, + "tencent/deepseek-v4-pro": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 3.625e-09, + "input_cost_per_token": 4.35e-07, + "input_cost_per_token_cache_hit": 3.625e-09, + "litellm_provider": "tencent", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 8.7e-07, + "source": "https://www.tencentcloud.com/products/tokenhub", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false + }, + "tencent/deepseek-v4-flash": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 2.8e-09, + "input_cost_per_token": 1.4e-07, + "input_cost_per_token_cache_hit": 2.8e-09, + "litellm_provider": "tencent", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "source": "https://www.tencentcloud.com/products/tokenhub", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": false + }, "pinstripes/ps/glm-4.5-air": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, - "input_cost_per_token": 0.000000125, - "output_cost_per_token": 0.00000045, + "input_cost_per_token": 1.25e-07, + "output_cost_per_token": 4.5e-07, "litellm_provider": "pinstripes", "mode": "chat", "supports_function_calling": true, @@ -43712,8 +45266,8 @@ "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, - "input_cost_per_token": 0.00000014, - "output_cost_per_token": 0.00000045, + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 4.5e-07, "litellm_provider": "pinstripes", "mode": "chat", "supports_function_calling": true, @@ -43725,8 +45279,8 @@ "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, - "input_cost_per_token": 0.00000009, - "output_cost_per_token": 0.0000002, + "input_cost_per_token": 9e-08, + "output_cost_per_token": 2e-07, "litellm_provider": "pinstripes", "mode": "chat", "supports_function_calling": true, @@ -43738,8 +45292,8 @@ "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, - "input_cost_per_token": 0.0000003, - "output_cost_per_token": 0.0000006, + "input_cost_per_token": 3e-07, + "output_cost_per_token": 6e-07, "litellm_provider": "pinstripes", "mode": "chat", "supports_function_calling": true, @@ -43751,8 +45305,8 @@ "max_tokens": 163840, "max_input_tokens": 163840, "max_output_tokens": 163840, - "input_cost_per_token": 0.0000001, - "output_cost_per_token": 0.0000002, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 2e-07, "litellm_provider": "pinstripes", "mode": "chat", "supports_function_calling": true, @@ -43764,8 +45318,8 @@ "max_tokens": 1000192, "max_input_tokens": 1000192, "max_output_tokens": 1000192, - "input_cost_per_token": 0.000000255, - "output_cost_per_token": 0.00000055, + "input_cost_per_token": 2.55e-07, + "output_cost_per_token": 5.5e-07, "litellm_provider": "pinstripes", "mode": "chat", "supports_function_calling": true, @@ -43773,39 +45327,61 @@ "supports_reasoning": false, "source": "https://pinstripes.io/pricing" }, - "fallback_generalizations": { - "rules": [ - { - "name": "anthropic-claude-adaptive-thinking", - "pattern": "(?:opus|sonnet|haiku)[-._](?:4[-._](?:[6-9]|[1-9]\\d)(?!\\d)|(?:[5-9]|[1-9]\\d{1,})[-._]\\d{1,2}(?!\\d))", - "description": "Claude opus/sonnet/haiku at version 4.6 or higher: 4.6 through 4.99, then any 5.x, 6.x or later major. The minor is capped at two digits so an 8-digit date suffix such as claude-opus-4-20250514 is never read as a >= 4.6 minor. Turns on adaptive thinking for new families with no code change.", - "extends": "anthropic-claude", - "model_info": { - "supports_adaptive_thinking": true - } - }, - { - "name": "anthropic-claude", - "pattern": "^claude-[a-z]+-\\d+[-.]\\d+(?:-\\d{8})?$", - "description": "Any Claude family-major-minor id, optionally with an 8-digit date suffix, anchored to the whole name. Version-neutral fallback that gives an unmapped Claude provider routing and baseline capabilities; it carries no pricing, so cost stays on the standard unpriced behavior rather than a guessed number.", - "model_info": { - "litellm_provider": "anthropic", - "mode": "chat", - "max_input_tokens": 200000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_vision": true, - "supports_tool_choice": true, - "supports_assistant_prefill": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_reasoning": true, - "supports_pdf_input": true, - "supports_system_messages": true - } - } - ] - } + "fallback_generalizations": { + "rules": [ + { + "name": "bedrock-claude-ids", + "pattern": "^(?:[a-z-]+\\.)?anthropic\\.claude-", + "description": "A Bedrock-syntax Claude id, for every version: anthropic.claude- at the start of the name, optionally behind a single dotted geo segment (us./eu./au./jp./apac./global./us-gov.). Anchored to the start because routing rules see the raw request string and provider inference feeds the proxy's provider/* wildcard access checks: an id under an unrecognized namespace such as bedrockz/anthropic.claude-... must stay unroutable rather than resolve to bedrock and slip through a bedrock/* key. Routes to bedrock before the bare-id Anthropic rule is consulted.", + "model_info": { + "litellm_provider": "bedrock" + } + }, + { + "name": "anthropic-claude-ids", + "pattern": "^claude-[a-z]+-\\d+(?:[-.]\\d+)?(?:-\\d{8})?$", + "description": "A bare Claude family-major id with an optional minor and an optional 8-digit date suffix, anchored to the whole name, so claude-newfamily-5 routes like claude-newfamily-5-1 does. Routes an unmapped Claude id that carries no provider namespace to the Anthropic API.", + "model_info": { + "litellm_provider": "anthropic" + } + }, + { + "name": "claude-family-baseline", + "pattern": "claude-[a-z]+-\\d+(?:[-.]\\d+)?(?:-\\d{8})?", + "description": "Any Claude family-major id with an optional minor and an optional 8-digit date suffix, under any provider namespace (bare, bedrock-dotted, vertex, databricks, ...), so bare majors like claude-newfamily-5 get the same baseline as claude-newfamily-5-1. Carries the model-family facts every Claude shares; it carries no pricing, so cost stays on the standard unpriced behavior rather than a guessed number.", + "model_info": { + "mode": "chat", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_vision": true, + "supports_tool_choice": true, + "supports_assistant_prefill": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_reasoning": true, + "supports_pdf_input": true, + "supports_system_messages": true + } + }, + { + "name": "claude-adaptive-thinking", + "pattern": "claude-[a-z]+-(?:4[-._](?:[6-9]|[1-9]\\d)(?!\\d)|(?:[5-9]|[1-9]\\d)(?!\\d)(?:[-._]\\d{1,2}(?!\\d))?)", + "description": "Claude at version 4.6 or higher, in any id shape that contains claude--: minors 4.6 through 4.99, any later major-minor, and bare 5+ majors so a new family shaped like claude-fable-5 matches. Requiring the claude- prefix keeps non-Claude names such as team-sonnet-5-1 out. The minor is capped at two digits so an 8-digit date suffix such as claude-opus-4-20250514 is never read as a >= 4.6 minor. Turns on adaptive thinking for new versions and new families with no code change.", + "model_info": { + "supports_adaptive_thinking": true + } + }, + { + "name": "claude-mid-conversation-system", + "pattern": "claude-[a-z]+-(?:4[-._](?:[89]|[1-9]\\d)(?!\\d)|(?:[5-9]|[1-9]\\d)(?!\\d)(?:[-._]\\d{1,2}(?!\\d))?)", + "description": "Claude at version 4.8 or higher, in any id shape that contains claude--: minors 4.8 through 4.99, any later major-minor, and bare 5+ majors so a new family like claude-fable-5 matches. Anthropic introduced mid-conversation system messages with Opus 4.8 and every newer Claude keeps them; 4.7 and below reject the system role inside messages.", + "model_info": { + "supports_mid_conversation_system": true + } + } + ] + } } diff --git a/litellm/models/managed_files.py b/litellm/models/managed_files.py index 24154768860..99ba764dd98 100644 --- a/litellm/models/managed_files.py +++ b/litellm/models/managed_files.py @@ -51,12 +51,12 @@ class LiteLLM_ManagedVectorStoreTable(LiteLLMPydanticObjectBase): class LiteLLM_ManagedVectorStoresTable(LiteLLMPydanticObjectBase): vector_store_id: str custom_llm_provider: str - vector_store_name: Optional[str] - vector_store_description: Optional[str] - vector_store_metadata: Optional[Dict[str, Any]] - created_at: Optional[datetime] - updated_at: Optional[datetime] - litellm_credential_name: Optional[str] - litellm_params: Optional[Dict[str, Any]] - team_id: Optional[str] - user_id: Optional[str] + vector_store_name: Optional[str] = None + vector_store_description: Optional[str] = None + vector_store_metadata: Optional[Dict[str, Any]] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + litellm_credential_name: Optional[str] = None + litellm_params: Optional[Dict[str, Any]] = None + team_id: Optional[str] = None + user_id: Optional[str] = None diff --git a/litellm/models/mcp_server.py b/litellm/models/mcp_server.py index 3d03eff6df8..af2efa822b0 100644 --- a/litellm/models/mcp_server.py +++ b/litellm/models/mcp_server.py @@ -83,16 +83,27 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): token_url: Optional[str] = None registration_url: Optional[str] = None oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None + # Token Exchange (OBO) fields — RFC 8693. ``audience`` is named for the RFC's + # request parameter (token-exchange only); RFC 8707 resource indicators are a + # separate concept named ``resource`` in the v2 egress types. A null + # ``subject_token_type`` means DEFAULT_SUBJECT_TOKEN_TYPE (litellm.types.mcp), + # applied at the egress build sites. + token_exchange_endpoint: Optional[str] = None + audience: Optional[str] = None + subject_token_type: Optional[str] = None + token_exchange_profile: Optional[str] = None allow_all_keys: bool = False available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False oauth_passthrough: bool = False + dcr_bridge: Optional[bool] = None is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None has_user_credential: Optional[bool] = None source_url: Optional[str] = None timeout: Optional[float] = None + max_concurrent_requests: Optional[int] = None approval_status: Optional[str] = Field( default="active", description="Approval status: 'pending_review', 'active', 'rejected'", diff --git a/litellm/models/verification_token.py b/litellm/models/verification_token.py index 77771a3aca6..84a287697b1 100644 --- a/litellm/models/verification_token.py +++ b/litellm/models/verification_token.py @@ -36,9 +36,11 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase): budget_reset_at: Optional[datetime] = None allowed_cache_controls: Optional[list] = [] allowed_routes: Optional[list] = [] + key_type: str | None = None permissions: Dict = {} model_spend: Dict = {} model_max_budget: Dict = {} + budget_fallbacks: dict[str, list[str]] = {} soft_budget_cooldown: bool = False blocked: Optional[bool] = None litellm_budget_table: Optional[dict] = None diff --git a/litellm/ocr/main.py b/litellm/ocr/main.py index 5716155361d..38f3f804e10 100644 --- a/litellm/ocr/main.py +++ b/litellm/ocr/main.py @@ -17,6 +17,9 @@ import litellm from litellm._logging import verbose_logger from litellm.constants import request_timeout from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.azure_ai.ocr.common_utils import ( + is_azure_document_intelligence_model, +) from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig, OCRResponse from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.rust_bridge import ocr as rust_ocr_bridge @@ -83,6 +86,8 @@ def _prepare_ocr_request( if doc_type not in ["document_url", "image_url"]: raise ValueError(f"Invalid document type: {doc_type}. Must be 'document_url', 'image_url', or 'file'") + caller_supplied_api_base = api_base is not None + ( model, custom_llm_provider, @@ -95,9 +100,14 @@ def _prepare_ocr_request( api_key=api_key, ) + suppress_dynamic_api_base = ( + not caller_supplied_api_base + and custom_llm_provider == "azure_ai" + and is_azure_document_intelligence_model(model) + ) if dynamic_api_key: api_key = dynamic_api_key - if dynamic_api_base: + if dynamic_api_base and not suppress_dynamic_api_base: api_base = dynamic_api_base ocr_provider_config = ProviderConfigManager.get_provider_ocr_config( @@ -191,8 +201,7 @@ def _rust_bridge_api_base( if prepared_request.api_base is not None: return prepared_request.api_base if prepared_request.custom_llm_provider == "azure_ai": - model = prepared_request.model.lower() - if "doc-intelligence" in model or "documentintelligence" in model: + if is_azure_document_intelligence_model(prepared_request.model): return resolve_secret("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT") return resolve_secret("AZURE_AI_API_BASE") return None diff --git a/litellm/passthrough/main.py b/litellm/passthrough/main.py index 66367513062..cdeedd7b522 100644 --- a/litellm/passthrough/main.py +++ b/litellm/passthrough/main.py @@ -171,7 +171,6 @@ def llm_passthrough_route( api_key: Optional[str] = None, request_query_params: Optional[dict] = None, request_headers: Optional[dict] = None, - allm_passthrough_route: bool = False, content: Optional[Any] = None, data: Optional[dict] = None, files: Optional[RequestFiles] = None, @@ -198,7 +197,7 @@ def llm_passthrough_route( from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager - _is_async = allm_passthrough_route + _is_async = bool(kwargs.get("allm_passthrough_route", False)) litellm_logging_obj = cast("LiteLLMLoggingObj", kwargs.get("litellm_logging_obj")) diff --git a/litellm/proxy/_experimental/mcp_server/auth/token_exchange.py b/litellm/proxy/_experimental/mcp_server/auth/token_exchange.py index 80e72fa2bf2..cd41dd648ee 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/token_exchange.py +++ b/litellm/proxy/_experimental/mcp_server/auth/token_exchange.py @@ -28,6 +28,7 @@ from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import ( build_token_endpoint_client_auth, ) from litellm.types.llms.custom_http import httpxSpecialProvider +from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE if TYPE_CHECKING: from litellm.types.mcp_server.mcp_server_manager import MCPServer @@ -35,8 +36,6 @@ if TYPE_CHECKING: # RFC 8693 grant type constant TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" -DEFAULT_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" - class TokenExchangeHandler: """Handles OAuth 2.0 Token Exchange (RFC 8693) for MCP servers. diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 2520c7e82a1..421f1dcfbea 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -1,26 +1,47 @@ import re +from datetime import datetime, timezone from typing import Dict, List, Optional, Set, Tuple, cast from fastapi import HTTPException from starlette.datastructures import Headers from starlette.requests import Request from starlette.types import Scope +from typing_extensions import assert_never +import litellm from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( + BridgeEnvelopeAdmitted, + BridgeEnvelopeInvalid, + NotBridgeEnvelope, + envelope_keys_from_master_key, + is_bridge_envelope_shaped, + resolve_bridge_envelope, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( + EnvelopeIdentity, +) from litellm.proxy._types import ( + UI_TEAM_ID, LiteLLM_TeamTable, ProxyException, SpecialHeaders, + SpecialMCPServerName, SpecialMCPServerNames, UserAPIKeyAuth, ) from litellm.proxy.auth.ip_address_utils import IPAddressUtils -from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.auth.user_api_key_auth import ( + _run_centralized_common_checks, + user_api_key_auth, +) +from litellm.proxy.common_utils.http_parsing_utils import _read_request_body from litellm.proxy.common_utils.user_api_key_cache import get_management_object_ttl from litellm.repositories.table_repositories import ( AgentsRepository, MCPServerRepository, ) +from litellm.types.mcp_server.mcp_server_manager import MCPServer def _parse_mcp_server_names_from_path(path: str, mcp_servers_header: Optional[List[str]] = None) -> Optional[List[str]]: @@ -218,6 +239,35 @@ class MCPRequestHandler: # when EVERY target is auth_type=oauth2 with delegate_auth_to_upstream # set; fails closed otherwise. validated_user_api_key_auth = UserAPIKeyAuth() + elif MCPRequestHandler._target_servers_are_true_passthrough( + path=request_route, + mcp_servers=mcp_servers, + client_ip=IPAddressUtils.get_mcp_client_ip(request), + ): + validated_user_api_key_auth = UserAPIKeyAuth() + elif ( + ( + bridge_delegate_target := MCPRequestHandler._single_dcr_bridge_delegate_target( + path=request_route, + mcp_servers=mcp_servers, + client_ip=IPAddressUtils.get_mcp_client_ip(request), + ) + ) + is not None + and oauth2_headers + and is_bridge_envelope_shaped(oauth2_headers["Authorization"]) + ): + # A single DCR-bridge oauth_delegate target carrying an envelope-shaped + # Authorization: open the envelope, admit under its recovered identity, and + # inject the inner upstream token for egress. A non-envelope bearer on the same + # server is NOT admitted here — it falls through to the oauth2 arm, which 401s. + validated_user_api_key_auth, mcp_server_auth_headers = await MCPRequestHandler._admit_dcr_bridge_delegate( + server=bridge_delegate_target, + authorization_value=oauth2_headers["Authorization"], + mcp_server_auth_headers=mcp_server_auth_headers, + request=request, + route=request_route, + ) elif oauth2_headers: # Authorization on a non-delegated server: the bearer must be a real # LiteLLM credential, so a failed validation is a genuine 401/403 and @@ -356,6 +406,7 @@ class MCPRequestHandler: # Inline imports avoid a circular dependency: mcp_server_manager imports # from this module. from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, global_mcp_server_manager, ) from litellm.types.mcp import MCPAuth @@ -381,10 +432,376 @@ class MCPRequestHandler: # fetches the upstream token automatically using stored credentials, # so allowing anonymous bypass would let any external caller invoke # tools authenticated as LiteLLM's service account. - if server.has_client_credentials: + # + # Resolve the flow rather than reading has_client_credentials directly: + # this is a security gate, and a legacy row whose oauth2_flow was never + # stamped still carries the M2M credential shape (client_id/secret + + # token_url, no authorization_url). Treating an unstamped-but-M2M-shaped + # row as non-M2M here would reopen the anonymous bypass the explicit + # column no longer closes on its own. Shares the one resolution helper + # with the egress backstop and the anonymous-delegate allowlist; all fail + # closed on the ambiguous shape and are removed together once no null rows + # remain. A pure-PKCE delegate server (no stored credentials) resolves to a + # non-M2M flow and keeps its bypass. + if MCPServerManager.effective_oauth2_flow(server) == "client_credentials": return False return True + @staticmethod + def _target_servers_are_true_passthrough( + path: str, mcp_servers: Optional[list[str]], client_ip: Optional[str] + ) -> bool: + """ + True only when EVERY MCP server the request targets is ``auth_type == true_passthrough``. + Fails closed when any target does not opt in or cannot be resolved. + + Used by :meth:`process_mcp_request` to skip LiteLLM admission auth entirely: the gateway is a + transparent proxy and the caller's ``Authorization`` is an upstream token, never a LiteLLM key. + Mirrors :meth:`_target_servers_delegate_auth_to_upstream`; a mixed-target request keeps normal auth. + """ + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.types.mcp import MCPAuth + + target_names = MCPRequestHandler._resolve_target_server_names(path=path, mcp_servers_header=mcp_servers) + if not target_names: + return False + + for name in target_names: + server = global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip) + if server is None or server.auth_type != MCPAuth.true_passthrough: + return False + return True + + @staticmethod + def _single_dcr_bridge_delegate_target( + path: str, mcp_servers: Optional[List[str]], client_ip: Optional[str] + ) -> Optional[MCPServer]: + """The one DCR-bridge ``oauth_delegate`` server this request targets, or ``None``. + + Returns the server only when EXACTLY ONE target resolves and it is both + ``is_oauth_delegate`` and ``is_dcr_bridge``. Fails closed (``None``) on a + multi-target request, an unresolved target, or a non-matching server, so the + envelope admission arm never fires for an aggregate scope or a server that did not + opt into the bridge. Mirrors :meth:`_target_servers_are_true_passthrough`. + """ + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + target_names = MCPRequestHandler._resolve_target_server_names(path=path, mcp_servers_header=mcp_servers) + if len(target_names) != 1: + return None + server = global_mcp_server_manager.get_mcp_server_by_name(target_names[0], client_ip=client_ip) + if server is None or not server.is_oauth_delegate or not server.is_dcr_bridge: + return None + # Egress resolves the injected per-server token only by alias / server_name; a server with + # neither cannot receive the forwarded token, so fail closed rather than admit-and-drop. + if not (server.server_name or server.alias): + return None + return server + + @staticmethod + async def _admit_dcr_bridge_delegate( + server: MCPServer, + authorization_value: str, + mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]], + request: Request, + route: str, + ) -> Tuple[UserAPIKeyAuth, Optional[Dict[str, Dict[str, str]]]]: + """Open the bridge envelope and admit the caller under the live key it references. + + The envelope's signature proves the user authenticated when it was minted, but + authorization is resolved fresh here rather than trusted from the envelope: the + sealed ``key_hash`` reloads the current ``UserAPIKeyAuth`` record, and the admitted + identity then runs through the standard pipeline's centralized policy gate, so the + key's present restrictions and revocation state gate the request instead of a + snapshot frozen at mint time. The inner upstream token is injected under the + server's per-server auth-header key so egress forwards it via the + ``PassthroughConfig`` override; the envelope ``Authorization`` the leak-defense + strips never reaches the upstream. A new headers dict is returned rather than + mutating the input. Fails closed with a 401 on an invalid or expired envelope, or + when the referenced key is missing, blocked, or expired, its owner is + SCIM-deactivated, or the centralized policy gate rejects it (blocked team or + project, org or budget limits). + + The sealed token is keyed alias-first, matching the order egress resolves + (``lookup_mcp_server_auth_in_headers`` tries ``alias`` before ``server_name``). Keying + under ``server_name`` would leave a caller-supplied ``x-mcp-{alias}-authorization`` at the + higher-priority alias slot, pairing the admitted identity with an attacker's upstream + credential; the alias-keyed injection overwrites any such caller value. + """ + from litellm.proxy.proxy_server import master_key + + if not master_key: + raise HTTPException(status_code=500, detail="Server misconfigured: master_key is not set") + + await MCPRequestHandler._run_pre_db_read_auth_checks(request=request, route=route) + + keys = envelope_keys_from_master_key(master_key) + result = resolve_bridge_envelope(authorization_value, keys, datetime.now(timezone.utc), server.server_id) + match result: + case BridgeEnvelopeAdmitted(): + header_key = server.alias or server.server_name + if header_key is None: + raise HTTPException(status_code=500, detail="Server misconfigured: MCP server has no routable name") + admitted = await MCPRequestHandler._reload_admitted_principal(result.identity) + await MCPRequestHandler._enforce_admitted_live_policy(admitted=admitted, request=request, route=route) + injected = {header_key: {"Authorization": result.upstream_authorization.get_secret_value()}} + new_headers = {**(mcp_server_auth_headers or {}), **injected} + return admitted, new_headers + case BridgeEnvelopeInvalid() | NotBridgeEnvelope(): + raise HTTPException(status_code=401, detail="Invalid or expired credential") + case _: + assert_never(result) + + @staticmethod + async def _run_pre_db_read_auth_checks(request: Request, route: str) -> None: + """Run the proxy-wide gates ``user_api_key_auth`` applies before any key lookup: the + request-size and body-safety limits, the IP allowlist, and the ``general_settings`` + route allowlist. The envelope arm bypasses ``user_api_key_auth`` (it opens the envelope + and reloads the identity itself), so without this a caller blocked by IP or hitting a + proxy route the allowlist forbids would be admitted through an envelope where the same + principal presented on the normal MCP admission path would be rejected. Runs before the + envelope crypto so a disallowed caller is turned away before any work, mirroring the + standard pipeline's pre-DB ordering. Violations raise the gate's own status (an IP or + route block is a 403, an oversized body its own limit error).""" + from litellm.proxy.auth.auth_utils import pre_db_read_auth_checks + + await pre_db_read_auth_checks( + request=request, + request_data=await _read_request_body(request=request), + route=route, + ) + + @staticmethod + async def _reload_admitted_principal(identity: EnvelopeIdentity) -> UserAPIKeyAuth: + """Reload the live litellm record the envelope's subject references. + + Dispatches on the sealed subject type: a ``key_hash`` reloads the virtual key that + minted the envelope (the scripted two-header client that presents a litellm key at the + token endpoint), a ``user_id`` reloads the user that authenticated interactively (the + DCR client, whose SSO login at the bridged authorize yields a user, not a key). Both + return a ``UserAPIKeyAuth`` the caller runs through the centralized policy gate, so + team/project/org/budget/SCIM enforcement is identical to the principal presenting + itself directly.""" + match identity.subject_type: + case "key_hash": + return await MCPRequestHandler._reload_admitted_key(identity.subject) + case "user_id": + return await MCPRequestHandler._reload_admitted_user(identity.subject) + case _: + assert_never(identity.subject_type) + + @staticmethod + async def _reload_admitted_user(user_id: str) -> UserAPIKeyAuth: + """Reload the live user an interactively-minted envelope references and admit them as + themselves. + + The DCR client authenticates via SSO at the bridged authorize, which yields a user + subject rather than a virtual key, so the envelope admits under the user's own + identity: the reloaded ``user_id`` and the user's own MCP object permission ride on the + returned ``UserAPIKeyAuth``, and the SAME ``get_allowed_mcp_servers`` the key path uses then + computes which servers the user may reach, so the user's litellm MCP grants and access groups + gate the request exactly as a key's do. Only the user's OWN object permission is bound: a + ``UserAPIKeyAuth`` carries a single ``team_id`` while a user may belong to many teams, so + team-inherited MCP grants for a user are a follow-up (they need a many-teams union + ``get_allowed_mcp_servers`` does not do off one auth object). The caller's centralized policy + gate enforces the user's live budget and org state, and a SCIM-deactivated owner fails closed. + + Error handling mirrors the key path's retryable-503 contract, but ``get_user_object`` defeats a + type-based check: where ``get_key_object`` raises a typed ``ProxyException`` for a missing key + and lets a DB outage propagate raw, ``get_user_object`` catches every DB failure and re-raises a + bare ``ValueError``, so a missing user and a real outage look identical and the original error + survives only as ``__context__``. ``_raise_503_if_db_unavailable`` therefore walks the cause + chain: a transient DB outage still surfaces as a retryable 503, while a missing user, or any + other non-outage resolution failure, fails closed as a 401 rather than an opaque 500. The + object-permission load shares this one boundary, so an outage there is classified the same + way (``get_object_permission`` itself swallows a failed load to ``None``, matching how + ``get_key_object`` best-effort-loads a key's object permission).""" + from litellm.proxy.auth.auth_checks import get_object_permission, get_user_object + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Server misconfigured: no database connection") + try: + user_object = await get_user_object( + user_id=user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + ) + # Resolve the user's own MCP object permission (get_user_object does not load it) so the shared + # get_allowed_mcp_servers can grant the user their litellm-granted servers. Reuses the same + # get_object_permission resolver the key and team paths use; no permission logic is duplicated. + object_permission = user_object.object_permission if user_object is not None else None + if user_object is not None and object_permission is None and user_object.object_permission_id: + object_permission = await get_object_permission( + object_permission_id=user_object.object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) + except (ProxyException, HTTPException): + raise HTTPException(status_code=401, detail="Invalid or expired credential") from None + except Exception as e: # noqa: BLE001 # a DB outage anywhere in the resolution is a retryable 503, not an opaque 500; anything else fails closed as 401 + MCPRequestHandler._raise_503_if_db_unavailable(e) + raise HTTPException(status_code=401, detail="Invalid or expired credential") from None + if user_object is None: + raise HTTPException(status_code=401, detail="Invalid or expired credential") + if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False: + raise HTTPException(status_code=401, detail="Invalid or expired credential") + return UserAPIKeyAuth( + user_id=user_object.user_id, + user_role=user_object.user_role, + object_permission=object_permission, + object_permission_id=user_object.object_permission_id, + ) + + @staticmethod + async def _reload_admitted_key(key_hash: str) -> UserAPIKeyAuth: + """Reload the live key record an admitted envelope references and re-check live policy. + + Resolving the current ``UserAPIKeyAuth`` (cache first, then DB) is what stops the + envelope from carrying frozen authority: the key's present team/org/object-permission + restrictions ride on the returned object, and a key that has since been deleted, + blocked, or expired fails closed with a 401 here rather than being admitted as an + unrestricted identity. ``get_key_object`` raises for a hash with no key row; a + blocked or expired row is rejected explicitly because ``get_key_object`` resolves a + row without applying those checks (the main ``user_api_key_auth`` pipeline enforces + them downstream, which this admission path bypasses). The owner's SCIM state is the + other builder-inline check mirrored here, so IdP offboarding revokes every envelope + minted under the user's keys rather than leaving them live until expiry. Team, + project, org, and budget state are NOT re-checked here; the caller runs the admitted + identity through ``_enforce_admitted_live_policy`` for those. + """ + from litellm.proxy.auth.auth_checks import get_key_object + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Server misconfigured: no database connection") + try: + key_object = await get_key_object( + hashed_token=key_hash, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) + except (ProxyException, HTTPException): + raise HTTPException(status_code=401, detail="Invalid or expired credential") from None + except Exception as e: # noqa: BLE001 # a DB outage during reload is a retryable 503, not an opaque 500 + MCPRequestHandler._raise_503_if_db_unavailable(e) + raise + if not MCPRequestHandler._admitted_key_is_active(key_object): + raise HTTPException(status_code=401, detail="Invalid or expired credential") + await MCPRequestHandler._reject_if_admitted_owner_scim_deactivated(key_object) + return key_object + + @staticmethod + def _raise_503_if_db_unavailable(e: Exception) -> None: + """Raise a retryable 503 when ``e`` means the auth database is unreachable, else return so the + caller applies its own fail-closed mapping. A DB outage must not masquerade as an auth failure + (401) or surface as an opaque 500; the caller retries. Mirrors ``UserAPIKeyAuthExceptionHandler``, + which renders a service-unavailable database error as 503 on the standard pipeline. + + Classifies across the ``__cause__``/``__context__`` chain, not just ``e`` itself: ``get_user_object`` + re-raises every DB failure as a bare ``ValueError``, so a type-based check on the top exception + would miss a real outage wrapped inside it.""" + from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler + + if PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(e): + raise HTTPException( + status_code=503, + detail="Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly.", + ) from None + + @staticmethod + async def _reject_if_admitted_owner_scim_deactivated(key_object: UserAPIKeyAuth) -> None: + """Fail closed with a 401 when the key's owning user was deactivated via SCIM. + + The standard pipeline enforces this inline in ``_user_api_key_auth_builder`` rather + than in ``common_checks``, so the centralized policy gate does not cover it; without + this mirror, IdP offboarding would leave the user's already-minted envelopes live + until expiry. A failed user lookup skips the gate (fail-open), matching the builder: + this is the one deliberately fail-open check in an otherwise fail-closed arm, so a + transient DB outage during this lookup admits the request rather than rejecting it, + keeping parity with how the standard pipeline treats the same lookup failure.""" + if key_object.user_id is None: + return + from litellm.proxy.auth.auth_checks import get_user_object + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + try: + user_object = await get_user_object( + user_id=key_object.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + ) + except Exception as e: # noqa: BLE001 # mirror the builder's fail-open user lookup; DB errors are of any type + verbose_logger.debug(f"bridge admission: user lookup failed, skipping SCIM gate: {e}") + user_object = None + if user_object is None or not isinstance(user_object.metadata, dict): + return + if user_object.metadata.get("scim_active") is False: + raise HTTPException(status_code=401, detail="Invalid or expired credential") + + @staticmethod + async def _enforce_admitted_live_policy(admitted: UserAPIKeyAuth, request: Request, route: str) -> None: + """Run the standard pipeline's authorization checks over the admitted identity. + + Mirrors the ``user_api_key_auth`` wrapper between the builder and its return: clear the + request-scoped ``budget_reservation`` on the reloaded identity, run the route gate + (``RouteChecks.should_call_route``) to enforce the identity's ``allowed_routes`` and any + disabled/admin-only route, then run ``_run_centralized_common_checks`` (the same gate every + builder path funnels through) for team-block, project-block, org, and budget. The route gate + closes a bypass: a key barred from MCP routes could otherwise mint an envelope at the token + endpoint (not itself an MCP route) and replay it against MCP, because the centralized checks + treat MCP as an inference route and never re-check ``allowed_routes``. + + Failures surface with the status the standard pipeline would give them, mirroring + ``UserAPIKeyAuthExceptionHandler``: a disallowed route is the route gate's own 403, an + over-budget identity is a 429, a sub-check that raised its own ``HTTPException``/ + ``ProxyException`` keeps that status, a transient database outage is a retryable 503, and + only a genuinely unresolvable failure (a blocked team/project raises a bare ``Exception``, + same as the standard pipeline's fallback) becomes the fail-closed 401. Collapsing every + failure to 401 was misleading: it told an over-budget but validly-authenticated caller their + credential was invalid, which on a DCR client reads as broken auth and can trigger a + pointless re-authorize loop that cannot fix a budget problem, and it masked a DB outage as an + auth error.""" + from litellm.proxy.auth.route_checks import RouteChecks + + admitted.budget_reservation = None + try: + RouteChecks.should_call_route(route=route, valid_token=admitted, request=request) + await _run_centralized_common_checks( + user_api_key_auth_obj=admitted, + request=request, + request_data=await _read_request_body(request=request), + route=route, + ) + except (HTTPException, ProxyException): + raise + except litellm.BudgetExceededError as e: + raise HTTPException(status_code=getattr(e, "status_code", 429), detail=str(e)) from None + except Exception as e: # noqa: BLE001 # untyped gate failure: retryable 503 for a DB outage, else fail closed 401 + MCPRequestHandler._raise_503_if_db_unavailable(e) + raise HTTPException(status_code=401, detail="Invalid or expired credential") from None + + @staticmethod + def _admitted_key_is_active(key_object: UserAPIKeyAuth) -> bool: + """False when the referenced key is blocked or past its expiry, so a revoked key + cannot be admitted through its still-unexpired envelope. Mirrors the active-key gate + the bridge token endpoint applies at mint time.""" + if key_object.blocked is True: + return False + expires = key_object.expires + if expires is None: + return True + expiry = expires if isinstance(expires, datetime) else datetime.fromisoformat(expires) + if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None: + expiry = expiry.replace(tzinfo=timezone.utc) + return expiry >= datetime.now(timezone.utc) + @staticmethod def _resolve_target_server_names(path: str, mcp_servers_header: Optional[List[str]]) -> List[str]: """ @@ -544,10 +961,19 @@ class MCPRequestHandler: ASGI headers are in format: List[List[bytes, bytes]] We need to convert them to the format Headers expects. + + Collapsing the ASGI list into a dict keeps the last value for a duplicated + header name, so a request carrying more than one ``Authorization`` is + rejected first: for the client-forwarded token modes the gateway relays the + caller's ``Authorization`` upstream, so a duplicate would make which token is + forwarded ambiguous (and diverge from what admission inspected). Multiple + ``Authorization`` headers is malformed for bearer auth anyway (RFC 9110: not + a comma-combinable field), so fail closed with a 400. """ + raw_headers = scope.get("headers", []) + MCPRequestHandler._reject_duplicate_authorization(raw_headers) try: # ASGI headers are list of [name: bytes, value: bytes] pairs - raw_headers = scope.get("headers", []) # Convert bytes to strings and create dict for Headers constructor headers_dict = {name.decode("latin-1"): value.decode("latin-1") for name, value in raw_headers} return Headers(headers_dict) @@ -556,6 +982,26 @@ class MCPRequestHandler: # Return empty Headers object with empty dict return Headers({}) + @staticmethod + def _reject_duplicate_authorization(raw_headers: object) -> None: + """Raise 400 when the raw ASGI headers carry more than one ``Authorization`` header.""" + if not isinstance(raw_headers, (list, tuple)): + return + count = 0 + for entry in raw_headers: + if not isinstance(entry, (list, tuple)) or len(entry) < 1: + continue + name = entry[0] + if isinstance(name, (bytes, bytearray)) and bytes(name).lower() == b"authorization": + count += 1 + elif isinstance(name, str) and name.lower() == "authorization": + count += 1 + if count > 1: + raise HTTPException( + status_code=400, + detail="Multiple Authorization headers are not allowed", + ) + @staticmethod async def get_allowed_mcp_servers( user_api_key_auth: Optional[UserAPIKeyAuth] = None, @@ -725,6 +1171,9 @@ class MCPRequestHandler: if not user_api_key_auth or not user_api_key_auth.team_id or not prisma_client: return None + if user_api_key_auth.team_id == UI_TEAM_ID: + return None + # Get the team object (which has object_permission already loaded) team_obj: Optional[LiteLLM_TeamTable] = await get_team_object( team_id=user_api_key_auth.team_id, @@ -1020,6 +1469,9 @@ class MCPRequestHandler: if user_api_key_auth is None or not user_api_key_auth.team_id or prisma_client is None: return [] + if user_api_key_auth.team_id == UI_TEAM_ID: + return [] + team_obj: Optional[LiteLLM_TeamTable] = await get_team_object( team_id=user_api_key_auth.team_id, prisma_client=prisma_client, @@ -1041,6 +1493,9 @@ class MCPRequestHandler: if object_permissions is None: return list(set(team_access_group_servers)) + if SpecialMCPServerName.all_proxy_servers.value in (object_permissions.mcp_servers or []): + return list(global_mcp_server_manager.get_registry().keys()) + direct_mcp_servers = global_mcp_server_manager.expand_permission_list(object_permissions.mcp_servers or []) legacy_access_group_servers = await MCPRequestHandler._get_mcp_servers_from_access_groups( @@ -1499,6 +1954,9 @@ class MCPRequestHandler: verbose_logger.debug("prisma_client is None") return [] + if user_api_key_auth.team_id == UI_TEAM_ID: + return [] + try: team_obj: Optional[LiteLLM_TeamTable] = await get_team_object( team_id=user_api_key_auth.team_id, diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py new file mode 100644 index 00000000000..19048e2eb7c --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -0,0 +1,694 @@ +"""Bridge token flow: litellm identity resolution and the DCR-bridge oauth_delegate mint/refresh pipeline.""" + +import math +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Literal, Optional + +from fastapi import HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import SecretStr +from typing_extensions import assert_never + +from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.oauth_utils import TOKEN_NO_CACHE_HEADERS +from litellm.types.mcp_server.mcp_server_manager import MCPServer + +if TYPE_CHECKING: + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import _BridgeAuthorizationCode + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( + EnvelopeIdentity, + EnvelopeKeys, + RefreshCredential, + UpstreamTokenGrant, + ) + from litellm.proxy._types import UserAPIKeyAuth + + +def _litellm_key_from_request(request: Request) -> Optional[str]: + """Return the LiteLLM API key presented on the request, or ``None``. + + Accepts the key from ``x-litellm-api-key`` (what MCP clients such as Claude Desktop/Code + send) as well as ``Authorization``; either may carry a bare token or ``Bearer ``. + ``x-litellm-api-key`` wins when both are present, since ``Authorization`` may instead carry + an OAuth/upstream bearer. + """ + for header_value in ( + request.headers.get("x-litellm-api-key"), + request.headers.get("Authorization") or request.headers.get("authorization"), + ): + if not header_value: + continue + value = header_value.strip() + if value.lower().startswith("bearer "): + value = value[7:].strip() + if value: + return value + return None + + +def _key_is_active(key_obj: "UserAPIKeyAuth") -> bool: + """``True`` when the presented key is neither blocked nor past its expiry. + + The OAuth token endpoint is unauthenticated, so the presented key is validated here before it is + trusted; a revoked or expired key must not mint a bridge envelope or write a stored credential. + ``get_key_object`` resolves a row without these checks (the main ``user_api_key_auth`` pipeline + enforces them downstream, which this endpoint bypasses), so they are applied here. Deleted keys + are already rejected upstream, where ``get_key_object`` raises on a row that no longer exists. + + This is an active-state gate only; it deliberately does not require a ``user_id``. A valid + team-scoped or service-account key has no ``user_id`` yet is a legitimate credential, so gating + on ``user_id`` presence would wrongly reject it. Callers that need the user (the per-user token + store) derive it separately via :func:`_active_key_user_id`. + + Total by design: ``expires`` is typed ``str | datetime``, and an unparseable string would make + ``datetime.fromisoformat`` raise. Since the callers run this outside their key-resolution + ``try``, an uncaught parse error would surface as a 500 instead of the endpoint's fail-closed + behavior, so a malformed expiry is treated as inactive (return ``False``) rather than raising. + """ + if key_obj.blocked is True: + return False + expires = key_obj.expires + if expires is not None: + if isinstance(expires, datetime): + expiry = expires + else: + try: + expiry = datetime.fromisoformat(expires) + except (ValueError, TypeError): + return False + if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None: + expiry = expiry.replace(tzinfo=timezone.utc) + if expiry < datetime.now(timezone.utc): + return False + return True + + +def _active_key_user_id(key_obj: "UserAPIKeyAuth") -> str | None: + """The active key's ``user_id``, or ``None`` when the key is blocked/expired or simply has no + ``user_id`` (a team-scoped or service-account key). Used only by the per-user token store, which + needs a user to key the stored credential; the bridge mint uses the key hash and does not.""" + return key_obj.user_id if _key_is_active(key_obj) else None + + +@dataclass(frozen=True, slots=True) +class _ResolvedKey: + """An active litellm key resolved from the token request: its hash (the value ``get_key_object`` + and the cache/DB layer key the record by) and the live record.""" + + key_hash: str + key: "UserAPIKeyAuth" + + +_KeyResolutionFailure = Literal["no_active_key", "unavailable", "unresolvable"] +"""Why a token request yielded no active litellm key, kept distinct so a caller statuses each truthfully +instead of blaming the client for a gateway problem: +- ``no_active_key``: none was presented, or the presented key is unknown / blocked / expired (the + caller's request is at fault) +- ``unavailable``: the auth database was transiently unreachable while resolving (retryable) +- ``unresolvable``: the gateway cannot resolve identity right now (no DB connection, or an unexpected + error) -- a gateway fault, not the caller's +The classification mirrors admission's ``_reload_admitted_key`` so the mint (ingress) and admission +(egress) never disagree on the status of the same outage.""" + + +async def _resolve_active_litellm_key(request: Request) -> "_ResolvedKey | _KeyResolutionFailure": + """Resolve the presented litellm key to an active key record, or say precisely why not. + + Single resolution path the OAuth token endpoint reuses, resolving authoritatively via + ``get_key_object`` (cache first, then DB). The failure is a value, not a bare ``None``, so a caller + can tell "the client sent no usable credential" (a request error) apart from "the gateway could not + check" (an infrastructure error) and status each truthfully; collapsing both to ``None`` is what let + a DB outage read as a 400. A resolved key is still gated by ``_key_is_active``, so a blocked or + expired key is ``no_active_key`` while a valid team-scoped or service-account key (no ``user_id``) + resolves. Classification mirrors admission's ``_reload_admitted_key``: no DB connection is a gateway + fault, a ``ProxyException`` / ``HTTPException`` from ``get_key_object`` is an unknown or invalid key, + a database-service-unavailable error is a retryable outage, and anything else is an unexpected + gateway fault.""" + token = _litellm_key_from_request(request) + if not token: + return "no_active_key" + from litellm.proxy._types import hash_token # noqa: PLC0415 # inline import avoids a module-load circular import + + return await _reload_active_key_by_hash(hash_token(token)) + + +async def _reload_active_key_by_hash(key_hash: str) -> "_ResolvedKey | _KeyResolutionFailure": + """Reload the live key record for ``key_hash`` (cache first, then DB) and gate it on active state, + returning the resolved key or a precise failure. Shared by the token request's presented-key + resolution (:func:`_resolve_active_litellm_key`, which hashes the presented key) and the refresh + path (which already holds the hash sealed in the refresh envelope), so both re-validate identity + through one active-key gate and one failure classification. Classification mirrors admission's + ``_reload_admitted_key``: no DB connection is a gateway fault, a ``ProxyException`` / ``HTTPException`` + from ``get_key_object`` is an unknown or invalid key, a database-service-unavailable error is a + retryable outage, and anything else is an unexpected gateway fault. A blocked or expired key is + ``no_active_key``, so a revoked key can neither mint nor refresh a bridge envelope.""" + from litellm.proxy._types import ( + ProxyException, # noqa: PLC0415 # inline import avoids a module-load circular import + ) + from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import + get_key_object, + ) + from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import + PrismaDBExceptionHandler, + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import + prisma_client, + user_api_key_cache, + ) + + if prisma_client is None: + return "unresolvable" + try: + key_obj = await get_key_object( + hashed_token=key_hash, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) + except (ProxyException, HTTPException): + return "no_active_key" + except Exception as exc: # noqa: BLE001 # classify: a DB outage is retryable, anything else is an opaque gateway fault + if PrismaDBExceptionHandler.is_database_service_unavailable_error(exc): + return "unavailable" + verbose_logger.debug( + "_reload_active_key_by_hash: unexpected key-resolution error (%s)", + type(exc).__name__, + ) + return "unresolvable" + if not _key_is_active(key_obj): + return "no_active_key" + return _ResolvedKey(key_hash=key_hash, key=key_obj) + + +async def _reload_active_user_by_id(user_id: str) -> "_KeyResolutionFailure | None": + """Re-validate a live litellm user by id, returning ``None`` when the user is active or a precise + failure otherwise. The interactive DCR client authenticates via SSO, so its refresh envelope seals a + user subject; renewing it must re-check the user is still live (present and not SCIM-deactivated) so a + deactivated user cannot keep refreshing, mirroring how admission re-validates the same user subject on + the egress side. No DB connection is a gateway fault (``unresolvable``) and a + database-service-unavailable error is a retryable outage (``unavailable``). Everything else fails + closed as ``no_active_key`` (the caller maps it to invalid_grant): a ``ProxyException`` / + ``HTTPException``, a SCIM-deactivated user, and, unlike the key path, a missing user. ``get_user_object`` + catches every DB failure and re-raises a bare ``ValueError`` (a deleted user and a real outage look + identical, the original error surviving only as ``__context__``), so the outage check walks the cause + chain, and a missing user falls through to ``no_active_key`` rather than an opaque gateway fault.""" + from litellm.proxy._types import ( + ProxyException, # noqa: PLC0415 # inline import avoids a module-load circular import + ) + from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import + get_user_object, + ) + from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import + PrismaDBExceptionHandler, + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import + prisma_client, + user_api_key_cache, + ) + + if prisma_client is None: + return "unresolvable" + try: + user_object = await get_user_object( + user_id=user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + ) + except (ProxyException, HTTPException): + return "no_active_key" + except Exception as exc: # noqa: BLE001 # a DB outage is retryable; a missing user (get_user_object's wrapped ValueError) or any other resolution failure fails closed as no_active_key, never a 500 + if PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(exc): + return "unavailable" + verbose_logger.debug("_reload_active_user_by_id: user-resolution error (%s)", type(exc).__name__) + return "no_active_key" + if user_object is None: + return "no_active_key" + if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False: + return "no_active_key" + return None + + +async def _key_owner_scim_deactivated(key: "UserAPIKeyAuth") -> bool: + """True only when the key's owning user was explicitly SCIM-deactivated, so a refresh revokes an + offboarded owner's key exactly as admission does via ``_reject_if_admitted_owner_scim_deactivated``. + A key with no owner, a missing owner record, or a failed lookup fails OPEN (returns ``False``), + matching admission and the standard builder: a key may outlive its owner record, and a transient DB + blip must not revoke a live key. Only an explicit ``scim_active`` of ``False`` gates renewal.""" + if key.user_id is None: + return False + from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import + get_user_object, + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import + prisma_client, + user_api_key_cache, + ) + + if prisma_client is None: + return False + try: + owner = await get_user_object( + user_id=key.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + ) + except Exception as exc: # noqa: BLE001 # fail open: a missing owner (get_user_object's wrapped ValueError) or a DB blip must not revoke a live key + verbose_logger.debug("refresh: key-owner SCIM lookup failed, not revoking (%s)", type(exc).__name__) + return False + return owner is not None and isinstance(owner.metadata, dict) and owner.metadata.get("scim_active") is False + + +async def _revalidate_active_subject(identity: "EnvelopeIdentity") -> "_KeyResolutionFailure | None": + """Re-validate that the subject sealed in a refresh envelope is still live, dispatching on its type: + a key_hash reloads the virtual key, a user_id reloads the user. Returns ``None`` when the subject is + active or a precise failure otherwise, so revocation gates renewal for either identity source the same + way admission gates the egress: a blocked or expired key, a SCIM-deactivated key owner (mirroring + admission's owner check, so an offboarded user cannot keep renewing a still-active key), and a + deactivated or deleted user all fail closed to ``no_active_key``.""" + match identity.subject_type: + case "key_hash": + reloaded = await _reload_active_key_by_hash(identity.subject) + if not isinstance(reloaded, _ResolvedKey): + return reloaded + if await _key_owner_scim_deactivated(reloaded.key): + return "no_active_key" + return None + case "user_id": + return await _reload_active_user_by_id(identity.subject) + case _: + assert_never(identity.subject_type) + + +async def _extract_user_id_from_request(request: Request) -> str | None: + """The litellm ``user_id`` for the token request, so a per-user token is stored under the same + identity the egress later reads it by. Storage is best-effort, so every non-resolved outcome + (including a transient DB outage) collapses to ``None`` here and the caller simply skips the store; + the bridge mint, which must status those outcomes differently, consumes + :func:`_resolve_active_litellm_key` directly.""" + resolved = await _resolve_active_litellm_key(request) + if not isinstance(resolved, _ResolvedKey): + return None + return _active_key_user_id(resolved.key) + + +_UpstreamGrantRejection = Literal["no_access_token", "expired_lifetime"] +"""Why an upstream token response cannot back a bridge envelope: +- ``no_access_token``: the response carries no usable ``access_token`` +- ``expired_lifetime``: the response reports a parseable, non-positive ``expires_in``, i.e. an upstream + token that is already dead, so sealing it would forward a bearer the edge cannot use +An absent or unparseable ``expires_in`` is NOT a rejection; the lifetime is merely unknown and the +envelope caps it, the by-design behaviour for an upstream that omits the field.""" + + +def _classify_upstream_lifetime(raw_expires_in: object) -> "int | Literal['unspecified', 'expired']": + """Classify an upstream ``expires_in`` into a positive number of seconds, ``"unspecified"`` (absent + or unparseable, so the envelope caps it), or ``"expired"`` (a non-positive value the upstream reports + as already elapsed). Telling "we do not know the lifetime" apart from "the upstream says it is + already dead" is what stops an explicitly-expired token from silently receiving the envelope's 1h + cap. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a + positive sub-second lifetime in ``(0, 1)`` is not truncated to ``0`` and misread as elapsed; the + envelope works in whole seconds, so such a lifetime clamps up to its 1s floor. ``bool`` is excluded + (an ``int`` subclass but never a real lifetime), and the conversions can raise on ``NaN`` / + ``Infinity`` / oversized input, which reads as unparseable rather than surfacing as a 500.""" + if raw_expires_in is None or isinstance(raw_expires_in, bool) or not isinstance(raw_expires_in, (int, float, str)): + return "unspecified" + try: + numeric = float(raw_expires_in) + seconds = int(numeric) + except (ValueError, TypeError, OverflowError): + return "unspecified" + if numeric <= 0: + return "expired" + return max(1, seconds) + + +def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenGrant | _UpstreamGrantRejection": + """Validate an upstream OAuth token response into a typed grant, or say why it cannot back an + envelope. Each field is isinstance-checked so nothing untyped from ``response.json()`` reaches the + grant. ``expires_in`` is read three ways (see :func:`_classify_upstream_lifetime`): an unknown + lifetime leaves the grant ``expires_in`` ``None`` for the envelope to cap, a positive value is + honoured, and an explicit already-elapsed value is a rejection rather than a silent fall-through to + the cap.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + UpstreamTokenGrant, + ) + + if not isinstance(token_response, dict): + return "no_access_token" + access = token_response.get("access_token") + if not isinstance(access, str) or not access: + return "no_access_token" + lifetime = _classify_upstream_lifetime(token_response.get("expires_in")) + if lifetime == "expired": + return "expired_lifetime" + token_type = token_response.get("token_type") + scope = token_response.get("scope") + return UpstreamTokenGrant( + access_token=SecretStr(access), + token_type=token_type if isinstance(token_type, str) and token_type else "Bearer", + # The upstream refresh_token is deliberately NOT sealed: the edge never consumes it (it forwards + # only token_type + access_token), so it would be dead weight embedding a long-lived upstream + # credential in the client-held bearer, and it enlarges the envelope. Refresh support is a + # follow-up (a dedicated refresh-envelope); the client re-runs authorization_code at the cap. + refresh_token=None, + scope=scope if isinstance(scope, str) and scope else None, + expires_in=lifetime if isinstance(lifetime, int) else None, + ) + + +# --------------------------------------------------------------------------- +# DCR-bridge oauth_delegate mint: a three-phase pipeline whose failures are values. +# +# prepare (before the upstream exchange) -> validate every precondition and resolve identity+keys +# exchange (the single-use upstream code is consumed here, in exchange_token_with_server) +# finish (after the exchange) -> seal the upstream grant into the client-held envelope +# +# Every precondition lives in ``prepare``, which runs BEFORE the exchange, so no failure can burn the +# single-use code or rotate a refresh token, for either grant type -- that whole class of bug is gone +# by construction rather than guarded case by case. Failures are values mapped to an OAuth-shaped +# response in one place (``_bridge_mint_error_response``), so status codes and the RFC 6749 §5.2 body +# shape are uniform. Adding a failure mode is a new literal plus a match arm the type checker forces. +# --------------------------------------------------------------------------- + +_BridgeMintError = Literal[ + "no_identity", + "invalid_refresh", + "identity_unavailable", + "identity_unresolvable", + "not_configured", + "no_upstream_token", + "upstream_token_expired", + "too_large", +] + + +@dataclass(frozen=True, slots=True) +class _BridgeMintReady: + """Everything the seal needs, resolved once before the exchange: the identity to bind the envelope + to and the master-key-derived envelope keys. The identity is a key_hash subject for the scripted + two-header client (resolved from the litellm key it presents) or a user_id subject for the + interactive SSO client (the user recovered from the gateway authorization code), so one phase-3 seal + serves both. Resolving identity here means ``_finish_bridge_mint`` has no preconditions left to + fail.""" + + identity: "EnvelopeIdentity" + keys: "EnvelopeKeys" + + +def _bridge_mint_error_response(error: _BridgeMintError) -> JSONResponse: + """Map a bridge-mint failure value to its token-endpoint response: one place, RFC 6749 §5.2 shape + (top-level ``error``, no-store headers) for every case, with a status truthful about where the + failure is. The caller's request is 400, a transient gateway outage is 503, a gateway + misconfiguration is 500, and an upstream problem is 502. The identity-resolution statuses match how + admission statuses the same conditions on the egress side, so mint and admit never disagree under + one outage.""" + match error: + case "no_identity": + status, code, desc = ( + 400, + "invalid_request", + "this server issues a gateway-bound credential; complete the interactive sign-in, or " + "send a litellm credential (x-litellm-api-key or Authorization) on the token request", + ) + case "invalid_refresh": + status, code, desc = ( + 400, + "invalid_grant", + "the refresh credential is not a valid, live refresh envelope for this server; " + "re-run authorization_code to obtain a new one", + ) + case "identity_unavailable": + status, code, desc = ( + 503, + "temporarily_unavailable", + "the authentication database is temporarily unreachable; retry shortly", + ) + case "identity_unresolvable": + status, code, desc = ( + 500, + "server_error", + "the gateway could not resolve the litellm identity for this request", + ) + case "not_configured": + status, code, desc = ( + 500, + "server_error", + "the gateway is not configured to mint a gateway-bound credential (master_key is not set)", + ) + case "no_upstream_token": + status, code, desc = ( + 502, + "server_error", + "the upstream token response has no usable access_token", + ) + case "upstream_token_expired": + status, code, desc = ( + 502, + "server_error", + "the upstream token response reports an already-expired lifetime", + ) + case "too_large": + status, code, desc = ( + 502, + "server_error", + "the upstream token is too large to seal into a gateway-bound credential", + ) + case _: + assert_never(error) + return JSONResponse( + status_code=status, content={"error": code, "error_description": desc}, headers=TOKEN_NO_CACHE_HEADERS + ) + + +def _key_resolution_failure_to_mint_error(failure: _KeyResolutionFailure) -> _BridgeMintError: + """Lift an identity-resolution failure into the mint taxonomy, preserving origin so the status stays + truthful: the caller's missing credential is 400, a transient DB outage is 503, and a gateway that + cannot resolve identity is 500.""" + match failure: + case "no_active_key": + return "no_identity" + case "unavailable": + return "identity_unavailable" + case "unresolvable": + return "identity_unresolvable" + case _: + assert_never(failure) + + +def _upstream_rejection_to_mint_error(rejection: _UpstreamGrantRejection) -> _BridgeMintError: + """Lift an upstream-response rejection into the mint taxonomy; both are upstream faults (502).""" + match rejection: + case "no_access_token": + return "no_upstream_token" + case "expired_lifetime": + return "upstream_token_expired" + case _: + assert_never(rejection) + + +async def _prepare_bridge_mint( + request: Request, + mcp_server: MCPServer, + bridge_identity: "_BridgeAuthorizationCode | None" = None, +) -> "_BridgeMintReady | _BridgeMintError": + """Phase 1 for the authorization_code grant, BEFORE the upstream exchange: confirm the gateway can + mint (master_key set), resolve the litellm identity, and derive the envelope keys. Returns a ready + context or a precise failure value. Running before the exchange is what makes every failure here fail + closed without consuming the single-use code. + + Two identity sources, one envelope. The interactive DCR client authenticates via SSO at the bridged + authorize, so its identity arrives as ``bridge_identity`` (the user recovered from the gateway + authorization code) and mints a user subject. The scripted two-header client presents a litellm key + on the token request instead, so its identity is the active key's hash and mints a key_hash subject. + A missing or invalid presented key keeps its resolution origin so the mapper statuses it truthfully; + neither source present is ``no_identity``. The refresh_token grant has its own phase-1 + (:func:`_prepare_bridge_refresh`), which recovers identity from the presented refresh envelope.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import + envelope_keys_from_master_key, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + key_hash_identity, + user_identity, + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import + master_key, + ) + + if not master_key: + return "not_configured" + keys = envelope_keys_from_master_key(master_key) + if bridge_identity is not None: + identity = user_identity(server_id=mcp_server.server_id, user_id=bridge_identity.litellm_user_id) + return _BridgeMintReady(identity=identity, keys=keys) + resolved = await _resolve_active_litellm_key(request) + if not isinstance(resolved, _ResolvedKey): + return _key_resolution_failure_to_mint_error(resolved) + identity = key_hash_identity(server_id=mcp_server.server_id, key_hash=resolved.key_hash) + return _BridgeMintReady(identity=identity, keys=keys) + + +@dataclass(frozen=True, slots=True) +class _BridgeRefreshReady: + """A validated refresh request: the identity+keys to mint the renewed pair under, the upstream refresh + token (unwrapped from the client's refresh envelope) to exchange with the upstream IdP, and the scope + sealed alongside it at mint. The upstream refresh token is a ``SecretStr`` like every other credential + in this layer, so a repr or a traceback that captures this value never exposes the raw upstream refresh + token in plaintext. ``upstream_scope`` carries the originally-granted scope so the renewal re-requests + it when the client (a DCR/MCP client that typically omits scope on refresh) sends none, keeping the + renewed token's scope stable against an upstream that would otherwise narrow or drop it.""" + + ready: "_BridgeMintReady" + upstream_refresh_token: SecretStr + upstream_scope: str | None = None + + +def _refresh_key_failure_to_mint_error(failure: _KeyResolutionFailure) -> _BridgeMintError: + """Lift an identity-resolution failure on the refresh path into the mint taxonomy. Unlike the mint + path, a resolved-but-inactive (or unknown) key is ``invalid_grant`` rather than ``invalid_request``: + the client did present an identity (sealed in the refresh envelope), but it is no longer live, so the + refresh is invalid and the client must re-authenticate. A transient outage is still 503 and a gateway + fault still 500, matching the mint path and admission.""" + match failure: + case "no_active_key": + return "invalid_refresh" + case "unavailable": + return "identity_unavailable" + case "unresolvable": + return "identity_unresolvable" + case _: + assert_never(failure) + + +async def _prepare_bridge_refresh( + mcp_server: MCPServer, refresh_value: str | None +) -> "_BridgeRefreshReady | _BridgeMintError": + """Phase 1 for the refresh_token grant, BEFORE the upstream exchange: open the client's refresh + envelope, re-validate the sealed litellm identity so a revoked key cannot keep refreshing, and + recover the upstream refresh token to exchange. Identity comes entirely from the sealed envelope, not + the HTTP request, so the request object is not needed here. The client presents a refresh envelope, + never a raw upstream refresh token, so a missing value, a non-envelope, an unopenable envelope, or one + minted for another server is ``invalid_grant``. Running before the exchange means a rejected refresh + never consumes or rotates the upstream refresh token.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import + BridgeRefreshOpened, + envelope_keys_from_master_key, + open_bridge_refresh_envelope, + ) + from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import + master_key, + ) + + if not master_key: + return "not_configured" + if not refresh_value: + return "invalid_refresh" + keys = envelope_keys_from_master_key(master_key) + opened = open_bridge_refresh_envelope(refresh_value, keys, datetime.now(timezone.utc), mcp_server.server_id) + if not isinstance(opened, BridgeRefreshOpened): + return "invalid_refresh" + failure = await _revalidate_active_subject(opened.identity) + if failure is not None: + return _refresh_key_failure_to_mint_error(failure) + return _BridgeRefreshReady( + ready=_BridgeMintReady(identity=opened.identity, keys=keys), + upstream_refresh_token=opened.refresh.refresh_token, + upstream_scope=opened.refresh.scope, + ) + + +def _finish_bridge_mint( + ready: "_BridgeMintReady", mcp_server: MCPServer, token_response: object, now: datetime +) -> "JSONResponse | _BridgeMintError": + """Phase 3, AFTER the upstream exchange: seal the upstream grant into the client-held access envelope + using the pre-resolved identity and keys, and, when the upstream returned a refresh token, seal a + long-lived refresh envelope alongside it so the client can renew without re-authenticating. Shared by + the authorization_code and refresh_token paths, so a renewal that the upstream rotates re-issues a + fresh refresh envelope. The only hard failures here are properties of the upstream access token (no + usable token, an already-expired lifetime, or a token too large to seal); a refresh token that cannot + be sealed degrades to an access-only response rather than failing the whole exchange.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import + build_bridge_token_response, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + SealedEnvelope, + UpstreamTokenGrant, + ) + + grant = _bridge_grant_from_token_response(token_response) + if not isinstance(grant, UpstreamTokenGrant): + return _upstream_rejection_to_mint_error(grant) + sealed = build_bridge_token_response(ready.identity, grant, ready.keys, now) + if not isinstance(sealed, SealedEnvelope): + return "too_large" + # Report expires_in from the JWT's own second-truncated exp, rounding the elapsed portion up, so the + # client is never told the bearer lives past the point admission (which uses that exp) rejects it. + expires_in = max(0, int(sealed.expires_at.timestamp()) - math.ceil(now.timestamp())) + refresh_envelope = _mint_refresh_envelope_value(ready.identity, token_response, ready.keys, now, mcp_server) + body = { + "access_token": sealed.token.get_secret_value(), + "token_type": "Bearer", + "expires_in": expires_in, + # A refresh envelope rides along only when the upstream returned a refresh token to seal; when it + # rotates on renewal, the client receives the new one and the old envelope's upstream token dies. + **({"refresh_token": refresh_envelope} if refresh_envelope is not None else {}), + } + return JSONResponse(body, headers=TOKEN_NO_CACHE_HEADERS) + + +def _upstream_refresh_credential(token_response: object) -> "RefreshCredential | None": + """Extract the upstream refresh grant from a token response, or ``None`` when there is none to seal. + Each field is isinstance-checked so nothing untyped reaches the refresh envelope; ``refresh_expires_in`` + (the refresh token's own lifetime, when the upstream reports it) is classified like ``expires_in`` and + bounds the refresh envelope's TTL. An upstream that reports the refresh token itself as already elapsed + (``refresh_expires_in`` non-positive) yields ``None`` rather than a refresh envelope: sealing a dead + token would hand the client a full-TTL-capped envelope the IdP will reject, so the exchange degrades to + an access-only response (the client re-authenticates at access expiry), mirroring how + :func:`_bridge_grant_from_token_response` refuses an already-elapsed access token instead of capping it.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + RefreshCredential, + ) + + if not isinstance(token_response, dict): + return None + refresh = token_response.get("refresh_token") + if not isinstance(refresh, str) or not refresh: + return None + lifetime = _classify_upstream_lifetime(token_response.get("refresh_expires_in")) + if lifetime == "expired": + return None + scope = token_response.get("scope") + return RefreshCredential( + refresh_token=SecretStr(refresh), + scope=scope if isinstance(scope, str) and scope else None, + expires_in=lifetime if isinstance(lifetime, int) else None, + ) + + +def _mint_refresh_envelope_value( + identity: "EnvelopeIdentity", token_response: object, keys: "EnvelopeKeys", now: datetime, mcp_server: MCPServer +) -> str | None: + """Seal the upstream refresh grant (if any) into a refresh envelope and return its bearer string, or + ``None`` when the upstream returned no refresh token or the refresh token is too large to seal. A + too-large refresh token degrades to an access-only response (logged) rather than failing an exchange + that already succeeded upstream: the client simply re-authenticates when the access envelope expires.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( # noqa: PLC0415 # inline import avoids a module-load circular import + build_bridge_refresh_token_response, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + SealedEnvelope, + ) + + refresh_credential = _upstream_refresh_credential(token_response) + if refresh_credential is None: + return None + sealed = build_bridge_refresh_token_response(identity, refresh_credential, keys, now) + if isinstance(sealed, SealedEnvelope): + return sealed.token.get_secret_value() + verbose_logger.warning( + "bridge mint: the upstream refresh token is too large to seal into a refresh envelope for " + "server=%s; issuing an access-only response, so the client re-authenticates at access expiry", + mcp_server.server_id, + ) + return None diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 1d62b325dec..97cefb3f2cb 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -3,7 +3,7 @@ import binascii import hashlib import json from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Union, cast +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Union, cast from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -46,6 +46,64 @@ from litellm.types.mcp import MCPCredentials if TYPE_CHECKING: from litellm.types.mcp_server.mcp_server_manager import MCPServer +_AUTH_FLOW_SCOPED_FIELDS: frozenset = frozenset( + { + "authorization_url", + "token_url", + "registration_url", + "oauth2_flow", + "dcr_bridge", + "token_exchange_endpoint", + "audience", + "subject_token_type", + "token_exchange_profile", + } +) + +# Token-exchange settings with dedicated columns that also exist on +# ``MCPCredentials`` as a legacy shape (rows and REST callers that predate the +# columns). Every write lifts blob values into the columns and strips them from +# the stored blob, so the read-time ``column or blob`` fallback only serves rows +# the current code has never written — a cleared column can then never be +# silently resurrected by a stale blob copy. These keys are stored plaintext +# (endpoints/identifiers, not secrets), so values lift as-is. +_TOKEN_EXCHANGE_COLUMN_FIELDS: frozenset = frozenset( + { + "token_exchange_endpoint", + "audience", + "subject_token_type", + "token_exchange_profile", + } +) + +# The client-forwarded token modes share one stored-credential shape: the admin-declared upstream +# OAuth app (client_id/client_secret) plus the same authorize relay, and neither mints anything the +# gateway keeps. So a switch WITHIN this class must preserve the stored app, unlike a cross-class +# switch (e.g. an oauth2 row whose client may be DCR-minted and is not reusable elsewhere). +_CLIENT_FORWARDED_AUTH_TYPES: frozenset = frozenset({"true_passthrough", "oauth_delegate"}) + +# Minted token material that must never survive a client rotation on a persisted row. +_MINTED_TOKEN_CREDENTIAL_FIELDS: frozenset = frozenset({"access_token", "refresh_token", "expires_in"}) + + +def _credential_auth_class(auth_type: Optional[str]) -> Optional[str]: + """Collapse the client-forwarded modes to one credential class; every other auth_type is its own + class. Used so credential handling keys off whether the stored-credential shape actually changed, + not off a raw auth_type inequality that treats true_passthrough<->oauth_delegate as a full reset.""" + if auth_type in _CLIENT_FORWARDED_AUTH_TYPES: + return "client_forwarded" + return auth_type + + +def _drop_stale_minted_on_client_rotation(merged: Dict[str, Any], new_creds: Dict[str, Any]) -> Dict[str, Any]: + """When the update rotates the client, drop stale minted token keys it did not itself set, so an old + app's access/refresh token never rides forward under the new client. A no-op when no client key changed.""" + if "client_id" not in new_creds and "client_secret" not in new_creds: + return merged + return { + key: value for key, value in merged.items() if key not in _MINTED_TOKEN_CREDENTIAL_FIELDS or key in new_creds + } + def _is_global_env_var_scope(scope: Any) -> bool: """``scope="user"`` entries are placeholders the user fills in; everything @@ -241,6 +299,14 @@ def _prepare_mcp_server_data( # Handle credentials serialization credentials = data_dict.get("credentials") if credentials is not None: + # Lift legacy blob-shaped token-exchange settings into their dedicated + # columns (an explicit top-level value wins, including an explicit + # null) and strip them from the blob so it never seeds the read-time + # fallback for rows written by current code. + for te_field in _TOKEN_EXCHANGE_COLUMN_FIELDS: + blob_value = credentials.pop(te_field, None) + if blob_value is not None and te_field not in data_dict: + data_dict[te_field] = blob_value data_dict["credentials"] = encrypt_credentials(credentials=credentials, encryption_key=_get_salt_key()) data_dict["credentials"] = safe_dumps(data_dict["credentials"]) @@ -521,7 +587,11 @@ async def delete_mcp_server_from_virtualkey(): pass -async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Optional[LiteLLM_MCPServerTable]: +async def delete_mcp_server( + prisma_client: PrismaClient, + server_id: str, + invalidate_token_cache: Optional[Callable[[str, str], Awaitable[None]]] = None, +) -> Optional[LiteLLM_MCPServerTable]: """ Delete the mcp server from the db by server_id @@ -532,6 +602,12 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti caller-visible error. Each table is cleaned independently so a failure on one still attempts the other. + Each enumerated credential row's user also gets their cached per-user token + invalidated (legacy cache + v2 store, via invalidate_token_cache, defaulting + to the manager's shared invalidation): the caches are keyed by + (user_id, server_id), so without this a re-created server reusing the same + server_id would serve tokens minted for the deleted server until TTL. + Returns the deleted mcp server record if it exists, otherwise None """ deleted_server = await MCPServerRepository(prisma_client).table.delete( @@ -540,6 +616,18 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti }, ) if deleted_server is not None: + credential_user_ids: List[str] = [] + try: + credential_rows = await prisma_client.db.litellm_mcpusercredentials.find_many( + where={"server_id": server_id} + ) + credential_user_ids = [row.user_id for row in credential_rows] + except Exception as e: # noqa: BLE001 - enumeration is best-effort; cached tokens expire by TTL + verbose_proxy_logger.warning( + "MCP server %s deleted but per-user credential enumeration failed; cached tokens expire by TTL: %s", + server_id, + e, + ) for model, label in ( (prisma_client.db.litellm_mcpusercredentials, "credential"), (prisma_client.db.litellm_mcpuserenvvars, "env var"), @@ -554,6 +642,15 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti label, e, ) + if credential_user_ids: + if invalidate_token_cache is None: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache + for user_id in credential_user_ids: + await invalidate_token_cache(user_id, server_id) return deleted_server @@ -603,29 +700,54 @@ async def update_mcp_server( # Pre-fetch existing record once if we need it for auth_type or credential logic existing = None has_credentials = "credentials" in data_dict and data_dict["credentials"] is not None - if data.auth_type or has_credentials: + # An explicit token-exchange column write (set or clear) also migrates the + # legacy blob copies below, so the existing row is needed for those updates. + explicit_te_write = bool(_TOKEN_EXCHANGE_COLUMN_FIELDS & data_dict.keys()) + if data.auth_type or has_credentials or explicit_te_write: existing = await MCPServerRepository(prisma_client).table.find_unique(where={"server_id": data.server_id}) - # Clear stale credentials when auth_type changes but no new credentials provided - if ( + auth_type_changed = bool( data.auth_type - and "credentials" not in data_dict and existing - and existing.auth_type is not None - and existing.auth_type != data.auth_type - ): + and _credential_auth_class(existing.auth_type) != _credential_auth_class(data.auth_type) + ) + + # Clear stale credentials when auth_type changes but no new credentials provided + if auth_type_changed and "credentials" not in data_dict: data_dict["credentials"] = None + if auth_type_changed: + data_dict.update({field: None for field in _AUTH_FLOW_SCOPED_FIELDS if field not in data_dict}) + + # An explicit column write that does not touch credentials must still migrate + # the row's legacy blob copies: lift values for columns the caller left + # untouched, strip every copy from the blob. Without this, clearing a column + # (e.g. to re-enable RFC 9728/8414 discovery) would leave the blob copy in + # place, and the next credentials update's migrate-on-write would silently + # repopulate the column the admin just cleared. (When credentials ARE in the + # update, the merge below performs the same migration.) + if explicit_te_write and "credentials" not in data_dict and existing is not None and existing.credentials: + existing_creds = ( + json.loads(existing.credentials) if isinstance(existing.credentials, str) else dict(existing.credentials) + ) + if _TOKEN_EXCHANGE_COLUMN_FIELDS & existing_creds.keys(): + for te_field in _TOKEN_EXCHANGE_COLUMN_FIELDS: + legacy_value = existing_creds.pop(te_field, None) + if legacy_value is not None and te_field not in data_dict and getattr(existing, te_field, None) is None: + data_dict[te_field] = legacy_value + data_dict["credentials"] = safe_dumps(existing_creds) + # Merge credentials: preserve existing fields not present in the update. # Without this, a partial credential update (e.g. changing only region) # would wipe encrypted secrets that the UI cannot display back. if "credentials" in data_dict and data_dict["credentials"] is not None: if existing and existing.credentials: - # Only merge when auth_type is unchanged. Switching auth types - # (e.g. oauth2 → api_key) should replace credentials entirely - # to avoid stale secrets from the previous auth type lingering. - auth_type_unchanged = data.auth_type is None or data.auth_type == existing.auth_type - if auth_type_unchanged: + # Only merge when the credential CLASS is unchanged. A cross-class switch + # (e.g. oauth2 → api_key, or oauth2 → true_passthrough) replaces credentials + # entirely to avoid stale secrets from the previous class lingering; a switch + # within the client-forwarded class (true_passthrough ↔ oauth_delegate) keeps + # the same declared app and so must merge, not replace. + if not auth_type_changed: existing_creds = ( json.loads(existing.credentials) if isinstance(existing.credentials, str) @@ -636,13 +758,35 @@ async def update_mcp_server( if isinstance(data_dict["credentials"], str) else dict(data_dict["credentials"]) ) - # New values override existing; existing keys not in update are preserved - merged = {**existing_creds, **new_creds} + # New values override existing; existing keys not in update are preserved. A client + # rotation additionally drops the previous app's stale minted token keys. + merged = _drop_stale_minted_on_client_rotation({**existing_creds, **new_creds}, new_creds) + # Migrate-on-write for legacy rows: token-exchange settings the + # old blob shape carried move to their dedicated columns (unless + # the caller set the column this update, or the row already has + # one) and are never re-persisted in the blob. Stored plaintext, + # so the merged value lifts as-is. + for te_field in _TOKEN_EXCHANGE_COLUMN_FIELDS: + legacy_value = merged.pop(te_field, None) + if ( + legacy_value is not None + and te_field not in data_dict + and getattr(existing, te_field, None) is None + ): + data_dict[te_field] = legacy_value data_dict["credentials"] = safe_dumps(merged) # Add audit fields data_dict["updated_by"] = touched_by + # prisma-python rejects a raw ``None`` for a ``Json?`` field ("value is required but not set"); the + # clear paths above use ``None`` as the merge-skip sentinel, so translate it here to ``Json(None)``, + # which writes SQL null and reads back as ``None``. Done at the edge so the merge guards stay simple. + if "credentials" in data_dict and data_dict["credentials"] is None: + from prisma import Json # noqa: PLC0415 # local import: prisma may be ungenerated at module load in some tools + + data_dict["credentials"] = Json(None) + updated_mcp_server = await MCPServerRepository(prisma_client).table.update( where={"server_id": data.server_id}, data=data_dict, # type: ignore @@ -998,6 +1142,103 @@ async def list_user_oauth_credentials( return results +def _decrypted_credential_field(creds: Dict[str, object], field: str) -> object: + """Return one credential field decrypted with the global salt key; non-string and legacy + plaintext values come back unchanged (decrypt_value_helper returns the original on failure).""" + value = creds.get(field) + if not isinstance(value, str): + return value + return decrypt_value_helper( + value=value, + key=field, + exception_type="debug", + return_original_value=True, + ) + + +def mcp_oauth_token_identity(server: object) -> tuple[object, ...]: + """The upstream-OAuth-token-determining fields of an MCP server: the resource/audience (url, or + spec_path for OpenAPI servers), the OAuth mode/grant (auth_type, oauth2_flow), the + authorization-server endpoints, and the OAuth client + scopes. Mirrors the dashboard's + getOAuthAuthorizationIdentity. When any of these change on a server update, previously stored + per-user tokens were minted for the old identity and are stale. Excludes transport and + delegate_auth_to_upstream, which do not affect what token is minted (RFC 8707/8693). + + client_id/client_secret are compared decrypted: stored values are NaCl-encrypted with a fresh + nonce on every write, so comparing ciphertext would flag every routine save as an identity + change and purge tokens that are still valid.""" + creds = getattr(server, "credentials", None) + if isinstance(creds, str): + try: + parsed: object = json.loads(creds) + except ValueError: + parsed = None + else: + parsed = creds + creds_dict: Dict[str, object] = parsed if isinstance(parsed, dict) else {} + return ( + getattr(server, "url", None), + getattr(server, "spec_path", None), + getattr(server, "auth_type", None), + getattr(server, "oauth2_flow", None), + getattr(server, "authorization_url", None), + getattr(server, "token_url", None), + getattr(server, "registration_url", None), + _decrypted_credential_field(creds_dict, "client_id"), + _decrypted_credential_field(creds_dict, "client_secret"), + creds_dict.get("scopes"), + ) + + +async def purge_user_oauth_credentials_for_server( + prisma_client: PrismaClient, + server_id: str, + invalidate_token_cache: Optional[Callable[[str, str], Awaitable[None]]] = None, +) -> int: + """Delete every stored per-user OAuth token for a server and invalidate each user's cached + token everywhere it can be served from (the legacy per-user token cache and the v2 per-user OAuth + token store), so no user keeps a token minted for a superseded configuration. Called when a server + update changes a mint-relevant field (see mcp_oauth_token_identity). Returns the number of rows + removed. + + LiteLLM_MCPUserCredentials also stores BYOK API keys in the same column; only rows whose payload + decodes as an OAuth2 credential (see _decode_oauth_payload) are deleted, because a config change + only invalidates minted tokens, never a user's own stored key. Rows are therefore deleted per + (user_id, server_id) pair rather than by a blanket server_id filter. An OAuth row inserted while + the purge runs for a user not yet enumerated survives; a re-auth completing in the window for an + already-enumerated user is deleted along with the stale row (the pair delete cannot tell them + apart), which costs that user one extra re-auth and nothing else. + + invalidate_token_cache is injectable for tests; it defaults to the manager's shared + invalidate_user_oauth_token_cache, the single invalidation point for per-user tokens.""" + repo = MCPUserCredentialsRepository(prisma_client) + rows = await repo.table.find_many(where={"server_id": server_id}) + oauth_rows = [row for row in rows if _decode_oauth_payload(row.credential_b64) is not None] + if not oauth_rows: + return 0 + deleted_count = await repo.table.delete_many( + where={"server_id": server_id, "user_id": {"in": [row.user_id for row in oauth_rows]}} + ) + if invalidate_token_cache is None: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache + + for row in oauth_rows: + await invalidate_token_cache(row.user_id, server_id) + if deleted_count != len(oauth_rows): + verbose_proxy_logger.warning( + "MCP server %s: purge removed %d OAuth credential row(s) but %d were enumerated; " + "row(s) were deleted concurrently during the purge", + server_id, + deleted_count, + len(oauth_rows), + ) + return deleted_count + + async def refresh_user_oauth_token( prisma_client: PrismaClient, user_id: str, diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index d045d2a9e60..54aff86aab2 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1,14 +1,16 @@ import asyncio import html as _html import json +import secrets import time from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse import httpx from fastapi import APIRouter, Form, HTTPException, Request -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import ( @@ -19,6 +21,24 @@ from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import ( TokenEndpointAuthConfigError, build_token_endpoint_client_auth, ) +from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( + _bridge_mint_error_response, + _BridgeMintReady, + _BridgeRefreshReady, + _extract_user_id_from_request, + _finish_bridge_mint, + _prepare_bridge_mint, + _prepare_bridge_refresh, +) +from litellm.proxy._experimental.mcp_server.faults import ( + CallerRejected, + CredentialSource, + UpstreamProtocolFault, + classify_upstream_dcr_rejection, + classify_upstream_token_rejection, + dcr_fault_detail, + render_token_fault, +) from litellm.proxy._experimental.mcp_server.oauth_utils import ( TOKEN_NO_CACHE_HEADERS, get_request_base_url, @@ -31,11 +51,11 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( ) from litellm.proxy.common_utils.http_parsing_utils import _read_request_body from litellm.proxy.utils import get_server_root_path -from litellm.types.mcp import MCPAuth +from litellm.types.mcp import MCPAuth, MCPCredentials from litellm.types.mcp_server.mcp_server_manager import MCPServer if TYPE_CHECKING: - from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy._types import LiteLLM_MCPServerTable # TTL cache for upstream OAuth metadata fetched from pass-through MCP servers. # Keeps us from hammering the upstream IdP on each discovery request. @@ -89,6 +109,8 @@ def encode_state_with_base_url( code_challenge: Optional[str] = None, code_challenge_method: Optional[str] = None, client_redirect_uri: Optional[str] = None, + litellm_user_id: str | None = None, + mcp_server_id: str | None = None, ) -> str: """ Encode the base_url, original state, and PKCE parameters using encryption. @@ -99,6 +121,11 @@ def encode_state_with_base_url( code_challenge: PKCE code challenge from client code_challenge_method: PKCE code challenge method from client client_redirect_uri: Original redirect_uri from client + litellm_user_id: The SSO-authenticated litellm user captured at the bridge authorize + (interactive dcr_bridge oauth_delegate only); the callback seals it into the gateway + authorization code so the token mint can bind the envelope to this user + mcp_server_id: The bridge server the interactive flow targets, sealed alongside + litellm_user_id so the gateway code cannot be replayed against another server Returns: An encrypted string that encodes all values @@ -109,6 +136,8 @@ def encode_state_with_base_url( "code_challenge": code_challenge, "code_challenge_method": code_challenge_method, "client_redirect_uri": client_redirect_uri, + "litellm_user_id": litellm_user_id, + "mcp_server_id": mcp_server_id, } state_json = json.dumps(state_data, sort_keys=True) encrypted_state = encrypt_value_helper(state_json) @@ -136,6 +165,134 @@ def decode_state_hash(encrypted_state: str) -> dict: return state_data +_BRIDGE_AUTH_CODE_PREFIX = "llm_bcode_" + + +class _BridgeAuthorizationCode(BaseModel): + """The identity and upstream code the gateway seals into the authorization code it hands a DCR + client for an interactive dcr_bridge oauth_delegate sign-in, recovered at the token endpoint.""" + + model_config = ConfigDict(frozen=True) + upstream_code: str = Field(min_length=1) + litellm_user_id: str = Field(min_length=1) + mcp_server_id: str = Field(min_length=1) + + +def is_bridge_authorization_code(code: str) -> bool: + """Cheap prefix check that ``code`` is a gateway-sealed bridge authorization code rather than a + raw upstream code, so the token endpoint can route without decrypting.""" + return code.startswith(_BRIDGE_AUTH_CODE_PREFIX) + + +def seal_bridge_authorization_code(upstream_code: str, litellm_user_id: str, mcp_server_id: str) -> str: + """Seal the upstream authorization code and the SSO-captured litellm user into a gateway + authorization code. The DCR client only echoes this opaque value back at the token endpoint; the + gateway decrypts it there to recover the user (to bind the envelope) and the upstream code (to + exchange with the upstream), so a litellm identity captured in the browser at authorize survives + to the back-channel token call with nothing stored server-side. Encrypted with the repo's + authenticated symmetric helper (the same family the OAuth state uses), so the client can neither + read nor forge it.""" + payload = json.dumps( + {"upstream_code": upstream_code, "litellm_user_id": litellm_user_id, "mcp_server_id": mcp_server_id}, + sort_keys=True, + ) + return _BRIDGE_AUTH_CODE_PREFIX + encrypt_value_helper(payload) + + +def open_bridge_authorization_code(code: str) -> _BridgeAuthorizationCode | None: + """Recover the sealed identity and upstream code, or ``None`` when ``code`` is not a gateway + bridge code or does not decrypt / validate. Total over hostile input: a raw upstream code (the + scripted two-header path) returns ``None`` and the caller falls through to the existing + behavior.""" + if not is_bridge_authorization_code(code): + return None + decrypted = decrypt_value_helper( + code[len(_BRIDGE_AUTH_CODE_PREFIX) :], "bridge_authorization_code", return_original_value=False + ) + if not isinstance(decrypted, str): + return None + try: + return _BridgeAuthorizationCode.model_validate_json(decrypted) + except ValidationError: + return None + + +def _redirect_to_litellm_login(request: Request) -> RedirectResponse: + """Send an unauthenticated browser through litellm login before the interactive bridge authorize + can capture its identity. The bridge oauth_delegate flow seals the SSO user into the gateway code, + so a session is required; without one there is nothing to bind. After login the user re-initiates + the connection, which then finds the session cookie (the seamless return-to round-trip, which is + origin-validated against the control-plane URL, is a follow-up).""" + base_url = get_request_base_url(request) + return RedirectResponse(f"{base_url}/sso/key/generate") + + +# LIT-4197: some upstream authorization servers reject an over-long ``state`` +# (the encrypted OAuth session blob routinely exceeds their limit). The upstream +# only needs an opaque value it echoes back on ``/callback``, so we forward a +# short random handle and keep the encrypted session in a per-flow HttpOnly +# cookie bound to that handle. The browser carries the cookie across the +# upstream round trip, so the flow stays correct with no server-side session +# store (works across proxy replicas, unlike an in-process map). +_OAUTH_STATE_COOKIE_PREFIX = "mcp_oauth_state_" +_OAUTH_STATE_COOKIE_TTL_SECONDS = 600 +_OAUTH_STATE_HANDLE_BYTES = 32 + + +def _oauth_state_cookie_name(relay_state: str) -> str: + return f"{_OAUTH_STATE_COOKIE_PREFIX}{relay_state}" + + +def _oauth_state_cookie_path_and_secure(request: Request) -> tuple[str, bool]: + parsed = urlparse(get_request_base_url(request)) + return parsed.path or "/", parsed.scheme == "https" + + +def _set_oauth_state_cookie( + response: Response, + request: Request, + relay_state: str, + encoded_state: str, +) -> None: + path, secure = _oauth_state_cookie_path_and_secure(request) + response.set_cookie( + key=_oauth_state_cookie_name(relay_state), + value=encoded_state, + max_age=_OAUTH_STATE_COOKIE_TTL_SECONDS, + path=path, + secure=secure, + httponly=True, + samesite="lax", + ) + + +def _resolve_encoded_oauth_state(request: Request, state: str) -> str: + """Return the encrypted OAuth session for a ``/callback`` request. + + New flows carry it in a per-flow cookie keyed by the short handle we + forwarded upstream (the IdP echoes that handle back as ``state``). Flows + started before this change - or in flight across a deploy - carry the + encrypted blob directly in ``state``, so fall back to it when the cookie + is absent. + """ + cookie_value = request.cookies.get(_oauth_state_cookie_name(state)) + return cookie_value if cookie_value else state + + +def _clear_oauth_state_cookie(response: Response, request: Request, state: str) -> None: + cookie_name = _oauth_state_cookie_name(state) + if cookie_name not in request.cookies: + return + path, secure = _oauth_state_cookie_path_and_secure(request) + response.delete_cookie( + key=cookie_name, + path=path, + secure=secure, + httponly=True, + samesite="lax", + ) + + def _get_validated_client_redirect_uri(request: Request, state_data: Dict[str, Any]) -> str: """Return a trusted (same-origin, loopback, or ops-allowlisted) client redirect URI from OAuth state. @@ -236,90 +393,6 @@ def _validate_token_response( ) -def _litellm_key_from_request(request: Request) -> Optional[str]: - """Return the LiteLLM API key presented on the request, or ``None``. - - Accepts the key from ``x-litellm-api-key`` (what MCP clients such as Claude Desktop/Code - send) as well as ``Authorization``; either may carry a bare token or ``Bearer ``. - ``x-litellm-api-key`` wins when both are present, since ``Authorization`` may instead carry - an OAuth/upstream bearer. - """ - for header_value in ( - request.headers.get("x-litellm-api-key"), - request.headers.get("Authorization") or request.headers.get("authorization"), - ): - if not header_value: - continue - value = header_value.strip() - if value.lower().startswith("bearer "): - value = value[7:].strip() - if value: - return value - return None - - -def _active_key_user_id(key_obj: "UserAPIKeyAuth") -> Optional[str]: - """The key's ``user_id``, or ``None`` if the key is blocked or expired. - - The OAuth token endpoint is unauthenticated, so the presented key is validated here before its - identity is trusted to key a stored credential; a revoked or expired key must not be able to - write or overwrite the per-user OAuth token. ``get_key_object`` resolves a row without these - checks (the main ``user_api_key_auth`` pipeline enforces them downstream, which this endpoint - bypasses), so they are applied here. Deleted keys are already rejected upstream, where - ``get_key_object`` raises on a row that no longer exists. - """ - if key_obj.blocked is True: - return None - expires = key_obj.expires - if expires is not None: - expiry = expires if isinstance(expires, datetime) else datetime.fromisoformat(expires) - if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None: - expiry = expiry.replace(tzinfo=timezone.utc) - if expiry < datetime.now(timezone.utc): - return None - return key_obj.user_id - - -async def _extract_user_id_from_request(request: Request) -> Optional[str]: - """Resolve the LiteLLM ``user_id`` at the OAuth token endpoint so a per-user token is stored - under the same identity the egress later reads it by (``user_api_key_auth.user_id``). - - Resolves authoritatively via ``get_key_object`` (cache first, then DB) instead of a raw cache - peek. On a multi-replica gateway the token-exchange request can land on a worker whose in-memory - cache never saw the key, and a cross-replica Redis hit deserializes to a plain ``dict`` rather - than a ``UserAPIKeyAuth``; the previous code read only ``Authorization`` and did - ``getattr(cached, "user_id")`` with no ``model_type`` rehydration and no DB fallback, so it - silently returned ``None`` and the token was never persisted, which makes the egress 401 on every - reconnect. The resolved key is validated (``_active_key_user_id``) before its identity is trusted, - so a blocked or expired key cannot write. Returns ``None`` when no key is present, the key cannot - be resolved, or it is blocked/expired. - """ - token = _litellm_key_from_request(request) - if not token: - return None - try: - from litellm.proxy._types import hash_token # noqa: PLC0415 - from litellm.proxy.auth.auth_checks import get_key_object # noqa: PLC0415 - from litellm.proxy.proxy_server import ( # noqa: PLC0415 - prisma_client, - user_api_key_cache, - ) - - key_obj = await get_key_object( - hashed_token=hash_token(token), - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - ) - return _active_key_user_id(key_obj) - except Exception as exc: - verbose_logger.debug( - "_extract_user_id_from_request: could not resolve a LiteLLM user_id for the presented " - "key (%s); per-user token will not be stored server-side.", - type(exc).__name__, - ) - return None - - async def _store_per_user_token_server_side( server: MCPServer, user_id: str, @@ -380,6 +453,12 @@ async def _store_per_user_token_server_side( ) return # Don't warm Redis if DB write failed + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 + global_mcp_server_manager, + ) + + await global_mcp_server_manager.invalidate_user_oauth_token_cache(user_id, server.server_id) + # Warm the Redis cache so the first subsequent MCP call is a cache hit ttl = _compute_per_user_token_ttl(server, expires_in) await mcp_per_user_token_cache.set( @@ -391,8 +470,20 @@ async def _store_per_user_token_server_side( def _raise_if_not_oauth2(mcp_server: MCPServer) -> None: - """Reject a non-oauth2 server from the gateway's OAuth authorize/token/register flow.""" - if mcp_server.auth_type == MCPAuth.oauth2: + """Reject a server without upstream OAuth from the gateway's authorize/token/register flow. + + The client-forwarded token modes (``true_passthrough`` / ``oauth_delegate``) are allowed + through: the caller owns the upstream token, and this relayed flow is how a browser obtains + one against the upstream IdP (the admin UI's browser-only Authorize uses it). The minted + token is upstream-audienced and held by the caller; the gateway persists nothing for these + modes (``_persist_dcr_client_registration`` skips them unconditionally, so even the admin + Authorize path with ``persist_credentials`` enabled writes nothing to the server row). + """ + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # circular import with mcp_server_manager at module load + _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, + ) + + if mcp_server.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: return raise HTTPException( status_code=400, @@ -413,23 +504,86 @@ def _raise_unless_oauth2_discovery_server( mcp_server_name: Optional[str], description: str, ) -> None: - """404 a NAMED discovery request unless it resolves to an oauth2 server. + """404 a NAMED discovery request unless it resolves to an oauth2 or DCR-bridge server. A named server that is unknown (or hidden from the caller) and one that exists but is non-oauth2 both return the same 404, so the well-known discovery paths cannot be used to enumerate non-OAuth server names. Root discovery (no name) is unaffected, and pass-through servers are resolved by the caller before this runs. + DCR-bridge servers are admitted because they serve the gateway's own authorization + server metadata (the register, authorize, and token relays). """ if mcp_server_name is None: return if mcp_server is not None and mcp_server.auth_type == MCPAuth.oauth2: return + if mcp_server is not None and mcp_server.is_dcr_bridge: + return raise HTTPException( status_code=404, detail=f"MCP server '{mcp_server_name}' is {description}", ) +def _dcr_bridge_relays_client_registration(mcp_server: MCPServer) -> bool: + """True when a DCR-bridge server relays client registration to the upstream authorization + server instead of short-circuiting to an admin-configured OAuth client. In the relay arm the + upstream holds each client's own registration, so the authorize and token relays pass the + client's ``client_id`` and ``redirect_uri`` through verbatim and the authorization code + returns directly to the client's redirect URI without transiting the gateway. Gateway-side + redirect trust and the ``/callback`` state relay therefore only apply to the short-circuit + arm, where the upstream only knows the gateway's own callback.""" + return mcp_server.is_dcr_bridge and bool(mcp_server.registration_url) and not mcp_server.client_id + + +def _require_s256_pkce( + code_challenge: Optional[str], + code_challenge_method: Optional[str], +) -> Tuple[str, str]: + """DCR-bridge servers serve unauthenticated public OAuth clients, so the PKCE downgrade + paths (no challenge, or a non-S256 method; RFC 7636 defaults a missing method to ``plain``) + are rejected at the gateway instead of relying on upstream enforcement. Returns the + validated pair so callers get non-optional values.""" + if code_challenge and code_challenge_method == "S256": + return code_challenge, code_challenge_method + raise HTTPException( + status_code=400, + detail=( + "This server requires PKCE: send code_challenge with " + "code_challenge_method=S256 on the authorization request" + ), + ) + + +def _redirect_to_upstream_authorize( + *, + mcp_server: MCPServer, + client_id: str, + redirect_uri: str, + state: str, + code_challenge: str, + code_challenge_method: str, + response_type: Optional[str], + scope: Optional[str], +) -> RedirectResponse: + """The bridge relay arm's authorize redirect: every client-supplied parameter passes through + to the upstream authorize endpoint verbatim, no relay state cookie is set, and the upstream + enforces its own registered redirect binding for the client.""" + scope_value = scope or (" ".join(mcp_server.scopes) if mcp_server.scopes else None) + passthrough_params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "state": state, + "response_type": response_type or "code", + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + **({"scope": scope_value} if scope_value else {}), + } + parsed_auth_url = urlparse(mcp_server.authorization_url or "") + merged_params = {**dict(parse_qsl(parsed_auth_url.query)), **passthrough_params} + return RedirectResponse(urlunparse(parsed_auth_url._replace(query=urlencode(merged_params)))) + + async def authorize_with_server( request: Request, mcp_server: MCPServer, @@ -441,11 +595,28 @@ async def authorize_with_server( response_type: Optional[str] = None, scope: Optional[str] = None, ): - if mcp_server.auth_type != "oauth2": - raise HTTPException(status_code=400, detail="MCP server is not OAuth2") + _raise_if_not_oauth2(mcp_server) if mcp_server.authorization_url is None: raise HTTPException(status_code=400, detail="MCP server authorization url is not set") + if mcp_server.is_dcr_bridge: + # Enforce S256 PKCE on both bridge arms. The relay arm forwards the validated, + # now-non-optional pair to the upstream authorize; the short-circuit arm keeps + # calling this for its enforcement side effect, then falls through to the gateway + # /callback flow below, which reads the original code_challenge names. + bridge_challenge, bridge_method = _require_s256_pkce(code_challenge, code_challenge_method) + if _dcr_bridge_relays_client_registration(mcp_server): + return _redirect_to_upstream_authorize( + mcp_server=mcp_server, + client_id=client_id, + redirect_uri=redirect_uri, + state=state, + code_challenge=bridge_challenge, + code_challenge_method=bridge_method, + response_type=response_type, + scope=scope, + ) + # Trusted redirect_uri: same-origin, loopback, or ops-allowlisted. # The URI is encrypted into the OAuth state and decoded on # /callback to redirect the user back; a non-trusted URI would be @@ -454,18 +625,38 @@ async def authorize_with_server( parsed = urlparse(redirect_uri) base_url = urlunparse(parsed._replace(query="")) request_base_url = get_request_base_url(request) + + # Interactive dcr_bridge oauth_delegate sign-in: this arm runs the gateway /callback and /token in + # the loop, so the gateway can capture the litellm user here (from the browser's UI session) and + # carry it to the back-channel token mint. Seal the SSO user and the target server into the state; + # the callback reads them back to mint the gateway authorization code. A DCR client cannot present a + # litellm key, so the browser session is the only identity source; without one there is nothing to + # bind, so send the user through login first. Every other oauth2 server keeps the identity-less state. + litellm_user_id: str | None = None + if mcp_server.is_dcr_bridge and mcp_server.is_oauth_delegate: + from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # inline import avoids a module-load circular import + _user_id_from_session_cookie, + ) + + litellm_user_id = _user_id_from_session_cookie(request) + if litellm_user_id is None: + return _redirect_to_litellm_login(request) + encoded_state = encode_state_with_base_url( base_url=base_url, original_state=state, code_challenge=code_challenge, code_challenge_method=code_challenge_method, client_redirect_uri=redirect_uri, + litellm_user_id=litellm_user_id, + mcp_server_id=mcp_server.server_id if litellm_user_id else None, ) + relay_state = secrets.token_urlsafe(_OAUTH_STATE_HANDLE_BYTES) params = { "client_id": mcp_server.client_id if mcp_server.client_id else client_id, "redirect_uri": f"{request_base_url}/callback", - "state": encoded_state, + "state": relay_state, "response_type": response_type or "code", } if scope: @@ -482,7 +673,16 @@ async def authorize_with_server( existing_params = dict(parse_qsl(parsed_auth_url.query)) existing_params.update(params) final_url = urlunparse(parsed_auth_url._replace(query=urlencode(existing_params))) - return RedirectResponse(final_url) + response = RedirectResponse(final_url) + _set_oauth_state_cookie(response, request, relay_state, encoded_state) + return response + + +def _token_credential_source(mcp_server: MCPServer) -> CredentialSource: + """Mirrors the resolved-client rule in :func:`exchange_token_with_server`: when the server has a + stored client_id the gateway presents its own credentials upstream, so a credential rejection is + the operator's fault, not the caller's.""" + return "gateway_stored" if mcp_server.client_id else "caller_supplied" async def exchange_token_with_server( @@ -504,8 +704,12 @@ async def exchange_token_with_server( if mcp_server.token_url is None: raise HTTPException(status_code=400, detail="MCP server token url is not set") + # The id and secret must come from the same source. When the server-side client_id wins, + # falling back to the caller's secret pairs the persisted client with a foreign secret; the + # register short-circuit hands clients a placeholder secret ("dummy"), so a re-auth against a + # persisted public PKCE client (no stored secret) would send that placeholder and the IdP 401s. resolved_client_id = mcp_server.client_id if mcp_server.client_id else client_id - resolved_client_secret = mcp_server.client_secret if mcp_server.client_secret else client_secret + resolved_client_secret = mcp_server.client_secret if mcp_server.client_id else client_secret try: client_auth = build_token_endpoint_client_auth( auth_method=mcp_server.token_endpoint_auth_method, @@ -515,50 +719,123 @@ async def exchange_token_with_server( except TokenEndpointAuthConfigError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + bridge_identity: _BridgeAuthorizationCode | None = None + bridge_mint_ready: _BridgeMintReady | None = None + bridge_upstream_refresh: SecretStr | None = None + bridge_upstream_scope: str | None = None + refresh_request_scope: str | None = None + is_bridge = mcp_server.is_oauth_delegate and mcp_server.is_dcr_bridge + if grant_type == "refresh_token": - if not refresh_token: + # Phase 1 for a bridge refresh: open the client's refresh envelope, re-validate the sealed + # identity, and unwrap the real upstream refresh token BEFORE building token_data, so the exchange + # sends the upstream token and never the envelope. A failure returns without touching the upstream. + if is_bridge: + prepared_refresh = await _prepare_bridge_refresh(mcp_server, refresh_token) + if not isinstance(prepared_refresh, _BridgeRefreshReady): + return _bridge_mint_error_response(prepared_refresh) + bridge_mint_ready = prepared_refresh.ready + bridge_upstream_refresh = prepared_refresh.upstream_refresh_token + bridge_upstream_scope = prepared_refresh.upstream_scope + # A bridge server sends the unwrapped upstream refresh token recovered from the client's refresh + # envelope above; every other server sends the client's own refresh token verbatim. + upstream_refresh_token = ( + bridge_upstream_refresh.get_secret_value() if bridge_upstream_refresh is not None else refresh_token + ) + if not upstream_refresh_token: raise HTTPException( status_code=400, detail="refresh_token is required for refresh_token grant", ) token_data: dict = { "grant_type": "refresh_token", - "refresh_token": refresh_token, + "refresh_token": upstream_refresh_token, **client_auth.body, } - if scope: - token_data["scope"] = scope + refresh_request_scope = scope or bridge_upstream_scope + if refresh_request_scope: + token_data["scope"] = refresh_request_scope else: if not code: raise HTTPException( status_code=400, detail="code is required for authorization_code grant", ) + # Interactive dcr_bridge oauth_delegate: the client presents the gateway authorization code the + # callback sealed. Recover the SSO user and the real upstream code from it; the upstream exchange + # below uses the upstream code, and the mint binds the envelope to the recovered user. Bind the + # sealed server to this request so a code minted for one bridge server cannot be spent at another. + # A raw upstream code (scripted path) opens to None and the code is used as-is. + bridge_identity = open_bridge_authorization_code(code) + if bridge_identity is not None: + if bridge_identity.mcp_server_id != mcp_server.server_id: + raise HTTPException( + status_code=400, + detail="Authorization code was issued for a different MCP server", + ) + code = bridge_identity.upstream_code + bridge_token_relay = _dcr_bridge_relays_client_registration(mcp_server) + if bridge_token_relay and not redirect_uri: + raise HTTPException( + status_code=400, + detail=( + "redirect_uri is required for the authorization_code grant on this server; " + "send the same redirect_uri used on the authorization request" + ), + ) proxy_base_url = get_request_base_url(request) + resolved_redirect_uri = redirect_uri if bridge_token_relay else f"{proxy_base_url}/callback" token_data = { "grant_type": "authorization_code", "code": code, - "redirect_uri": f"{proxy_base_url}/callback", + "redirect_uri": resolved_redirect_uri, **client_auth.body, } if code_verifier: token_data["code_verifier"] = code_verifier - + # Phase 1 for a bridge authorization_code mint: resolve identity (the SSO user recovered above, or + # the presented litellm key) and the envelope keys BEFORE the exchange consumes the single-use code. + if is_bridge: + prepared = await _prepare_bridge_mint(request, mcp_server, bridge_identity) + if not isinstance(prepared, _BridgeMintReady): + return _bridge_mint_error_response(prepared) + bridge_mint_ready = prepared async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) - response = await async_client.post( - mcp_server.token_url, - headers={"Accept": "application/json", **client_auth.headers}, - data=token_data, - ) + try: + response = await async_client.post( + mcp_server.token_url, + headers={"Accept": "application/json", **client_auth.headers}, + data=token_data, + ) + if response is not None: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + fault = classify_upstream_token_rejection( + exc.response, + credential_source=_token_credential_source(mcp_server), + log_context=mcp_server.server_id, + ) + upstream_rejected_bridge_refresh = ( + is_bridge + and grant_type == "refresh_token" + and isinstance(fault, CallerRejected) + and fault.code == "invalid_grant" + ) + if upstream_rejected_bridge_refresh: + verbose_logger.info( + "bridge refresh: the upstream rejected the sealed refresh token for server=%s with " + "invalid_grant (revoked or expired at the IdP); returning invalid_grant so the client " + "re-runs authorization_code rather than an opaque upstream error", + mcp_server.server_id, + ) + return _bridge_mint_error_response("invalid_refresh") + return render_token_fault(fault) if response is None: raise HTTPException( status_code=502, detail="MCP upstream token endpoint returned no response", ) - - response.raise_for_status() token_response = response.json() - access_token = token_response["access_token"] # Validate token response against server-configured rules before any storage. # This rejects tokens from wrong Slack workspaces, Atlassian orgs, etc. @@ -598,8 +875,23 @@ async def exchange_token_with_server( mcp_server.server_id, ) + # A DCR-bridge oauth_delegate server hands the client a gateway-bound envelope (identity plus the + # upstream token) instead of the raw upstream token, so the one bearer both admits the caller and + # forwards the upstream credential. Only this mode mints; every other server returns the raw token. + if bridge_mint_ready is not None: + if refresh_request_scope and isinstance(token_response, dict) and not token_response.get("scope"): + token_response = {**token_response, "scope": refresh_request_scope} + # Phase 3: seal the upstream grant into the client-held envelope; failures map through the same + # OAuth-shaped response as the phase-1 preconditions. + minted = _finish_bridge_mint(bridge_mint_ready, mcp_server, token_response, datetime.now(timezone.utc)) + return minted if isinstance(minted, JSONResponse) else _bridge_mint_error_response(minted) + + raw_access_token = token_response.get("access_token") if isinstance(token_response, dict) else None + if not isinstance(raw_access_token, str) or not raw_access_token: + return render_token_fault(UpstreamProtocolFault(note="the upstream token response has no usable access_token")) + result = { - "access_token": access_token, + "access_token": raw_access_token, "token_type": token_response.get("token_type", "Bearer"), } @@ -614,6 +906,247 @@ async def exchange_token_with_server( return JSONResponse(result, headers=TOKEN_NO_CACHE_HEADERS) +class _DcrClientRegistration(BaseModel): + """RFC 7591 dynamic client registration response, narrowed to the fields the gateway + must persist to authenticate later token-endpoint calls. Extra members are ignored.""" + + client_id: str + client_secret: Optional[str] = None + token_endpoint_auth_method: Optional[str] = None + + +class _PersistedDcrCredentials(BaseModel): + client_id: Optional[str] = None + client_secret: Optional[str] = None + token_endpoint_auth_method: Optional[str] = None + redirect_uris: Optional[list[str]] = None + + +def _redirect_uri_not_registered(credentials: _PersistedDcrCredentials, current_redirect_uri: str) -> bool: + """Whether a persisted DCR client is positively known NOT to cover the current callback. + + A DCR client is bound to the redirect_uris it was registered with; if the proxy's + resolved public origin has since changed, every authorize built for it will be + rejected by the IdP. Clients persisted before ``redirect_uris`` was recorded (and + admin-configured clients, which never get a recording) return False so they are + grandfathered rather than re-registered, because re-minting a client_id orphans + every user's refresh tokens for that server.""" + recorded = credentials.redirect_uris + if not recorded: + return False + return current_redirect_uri not in recorded + + +def _get_persisted_dcr_credentials(credentials: object) -> Optional[_PersistedDcrCredentials]: + if not credentials: + return None + try: + return ( + _PersistedDcrCredentials.model_validate_json(credentials) + if isinstance(credentials, str) + else _PersistedDcrCredentials.model_validate(credentials) + ) + except ValidationError: + return None + + +def _decrypt_persisted_dcr_credential(value: Optional[str], key: str) -> Optional[str]: + if value is None: + return None + return decrypt_value_helper( + value=value, + key=key, + exception_type="debug", + return_original_value=True, + ) + + +def _apply_persisted_dcr_credentials(mcp_server: MCPServer, credentials: _PersistedDcrCredentials) -> bool: + client_id = _decrypt_persisted_dcr_credential(credentials.client_id, "client_id") + if not client_id: + return False + mcp_server.client_id = client_id + mcp_server.client_secret = _decrypt_persisted_dcr_credential(credentials.client_secret, "client_secret") + mcp_server.token_endpoint_auth_method = credentials.token_endpoint_auth_method + return True + + +async def _get_persisted_mcp_server_with_dcr_client_id( + mcp_server: MCPServer, +) -> Optional[tuple["LiteLLM_MCPServerTable", _PersistedDcrCredentials]]: + from litellm.proxy._experimental.mcp_server.db import get_mcp_server # noqa: PLC0415 + from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 + + try: + prisma_client = get_prisma_client_or_throw("Database not connected. Cannot read MCP OAuth client registration.") + persisted_mcp_server = await get_mcp_server( + prisma_client=prisma_client, + server_id=mcp_server.server_id, + ) + except Exception as exc: # noqa: BLE001 + verbose_logger.debug( + "register_client_with_server: failed to read persisted DCR client registration for server_id=%s: %s", + mcp_server.server_id, + exc, + ) + return None + + if persisted_mcp_server is None: + return None + + credentials = _get_persisted_dcr_credentials(persisted_mcp_server.credentials) + if credentials is None or not credentials.client_id: + return None + + return persisted_mcp_server, credentials + + +async def _reuse_persisted_dcr_client_if_available( + mcp_server: MCPServer, current_redirect_uri: Optional[str] = None +) -> bool: + persisted = await _get_persisted_mcp_server_with_dcr_client_id(mcp_server) + if persisted is None: + return False + persisted_mcp_server, credentials = persisted + if current_redirect_uri is not None and _redirect_uri_not_registered(credentials, current_redirect_uri): + verbose_logger.debug( + "register_client_with_server: not reusing persisted DCR client for server_id=%s; its registered " + "redirect_uris=%s do not include the current callback %s. The operator-facing warning for this " + "re-registration event is emitted once by _persisted_dcr_redirect_uri_is_stale.", + mcp_server.server_id, + credentials.redirect_uris, + current_redirect_uri, + ) + return False + if not _apply_persisted_dcr_credentials(mcp_server, credentials): + return False + + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 + global_mcp_server_manager, + ) + + try: + await global_mcp_server_manager.update_server(persisted_mcp_server) + except Exception as exc: # noqa: BLE001 + verbose_logger.warning( + "register_client_with_server: failed to refresh persisted DCR client registration for server_id=%s: %s", + mcp_server.server_id, + exc, + ) + return bool(mcp_server.client_id) + + +async def _persisted_dcr_redirect_uri_is_stale(mcp_server: MCPServer, current_redirect_uri: str) -> bool: + """Whether the server's persisted DCR client is bound to redirect_uris that no longer + cover the current proxy callback, meaning authorize is guaranteed to fail IdP-side. + + Consulted when the in-memory server already carries a hydrated client_id, which + otherwise short-circuits registration before any redirect check can run. Servers + without a persisted DCR recording (admin-configured client_id, or registered before + redirect_uris were recorded) are never reported stale.""" + persisted = await _get_persisted_mcp_server_with_dcr_client_id(mcp_server) + if persisted is None: + return False + _, credentials = persisted + if not _redirect_uri_not_registered(credentials, current_redirect_uri): + return False + verbose_logger.warning( + "register_client_with_server: persisted DCR client for server_id=%s is registered with redirect_uris=%s " + "which do not include the current callback %s (proxy origin changed); registering a replacement client. " + "Users previously signed in to this server will need to re-authenticate.", + mcp_server.server_id, + credentials.redirect_uris, + current_redirect_uri, + ) + return True + + +DcrRegistrationPersistenceResult = Literal["persisted", "reused", "skipped", "failed"] + + +async def _persist_dcr_client_registration( + mcp_server: MCPServer, registration_response: object, current_redirect_uri: str +) -> DcrRegistrationPersistenceResult: + """Persist the dynamically registered OAuth client (RFC 7591) onto the MCP server row. + + The interactive authorization_code flow mints a ``client_id`` via Dynamic Client + Registration that discovery cannot re-derive; without persisting it the autonomous + ``refresh_token`` grant has no client identity, so an expired access token forces a + full re-authorization instead of a silent refresh. Mirrors the ``encrypt_credentials`` + write that ``client_credentials`` and token exchange already use. Failures are logged, + never raised: registration still returns to the caller even when persistence fails. + + The client-forwarded token modes (``true_passthrough`` / ``oauth_delegate``) are skipped + unconditionally: the caller holds the upstream token and the gateway must hold no OAuth + client identity for these servers. Persisting here would stamp ``oauth2_flow`` and a + ``client_id`` onto a server whose mode promises the gateway stores nothing, making a + fresh pass-through server read as gateway-authorized. + + ``redirect_uris`` records what the client is bound to so a later origin change can be + detected as a positive mismatch and trigger re-registration instead of stranding the + server on IdP-side redirect_uri rejections. ``client_secret`` and + ``token_endpoint_auth_method`` are written explicitly (None when absent) because + ``update_mcp_server`` merges credential blobs: a re-registered public client must not + inherit the previous client's secret or auth method. + """ + if mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate: + return "skipped" + + try: + registration = _DcrClientRegistration.model_validate(registration_response) + except ValidationError as exc: + verbose_logger.warning( + "register_client_with_server: DCR response has no usable client_id for server_id=%s; " + "client registration not persisted (%s)", + mcp_server.server_id, + exc, + ) + return "failed" + + if await _reuse_persisted_dcr_client_if_available(mcp_server, current_redirect_uri=current_redirect_uri): + return "reused" + + credentials: MCPCredentials = { + "client_id": registration.client_id, + "client_secret": registration.client_secret, + "token_endpoint_auth_method": ( + "client_secret_basic" if registration.token_endpoint_auth_method == "client_secret_basic" else None + ), + "redirect_uris": [current_redirect_uri], + } + + from litellm.proxy._experimental.mcp_server.db import update_mcp_server # noqa: PLC0415 + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 + global_mcp_server_manager, + ) + from litellm.proxy._types import UpdateMCPServerRequest # noqa: PLC0415 + from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 + + try: + prisma_client = get_prisma_client_or_throw( + "Database not connected. Cannot persist MCP OAuth client registration." + ) + updated_row = await update_mcp_server( + prisma_client=prisma_client, + data=UpdateMCPServerRequest( + server_id=mcp_server.server_id, + credentials=credentials, + oauth2_flow="authorization_code", + **({"token_url": mcp_server.token_url} if mcp_server.token_url else {}), + ), + touched_by="mcp_oauth_dcr", + ) + await global_mcp_server_manager.update_server(updated_row) + return "persisted" + except Exception as exc: # noqa: BLE001 + verbose_logger.warning( + "register_client_with_server: failed to persist DCR client registration for server_id=%s: %s", + mcp_server.server_id, + exc, + ) + return "failed" + + async def register_client_with_server( request: Request, mcp_server: MCPServer, @@ -622,16 +1155,29 @@ async def register_client_with_server( response_types: Optional[list], token_endpoint_auth_method: Optional[str], fallback_client_id: Optional[str] = None, + persist_credentials: bool = False, + client_redirect_uris: Optional[list] = None, ): _raise_if_not_oauth2(mcp_server) request_base_url = get_request_base_url(request) + current_redirect_uri = f"{request_base_url}/callback" dummy_return = { "client_id": fallback_client_id or mcp_server.server_name, "client_secret": "dummy", - "redirect_uris": [f"{request_base_url}/callback"], + "redirect_uris": [current_redirect_uri], } - if mcp_server.client_id and mcp_server.client_secret: + if mcp_server.client_id and not ( + persist_credentials + and mcp_server.registration_url + and await _persisted_dcr_redirect_uri_is_stale(mcp_server, current_redirect_uri) + ): + return dummy_return + + if await _reuse_persisted_dcr_client_if_available( + mcp_server, + current_redirect_uri=current_redirect_uri if persist_credentials else None, + ): return dummy_return if mcp_server.authorization_url is None: @@ -640,12 +1186,19 @@ async def register_client_with_server( if mcp_server.registration_url is None: return dummy_return + bridge_relay = _dcr_bridge_relays_client_registration(mcp_server) + if bridge_relay and not client_redirect_uris: + raise HTTPException( + status_code=400, + detail="redirect_uris is required to register a client with this server", + ) + register_data = { "client_name": client_name, - "redirect_uris": [f"{request_base_url}/callback"], - "grant_types": grant_types or [], - "response_types": response_types or [], - "token_endpoint_auth_method": token_endpoint_auth_method or "", + "redirect_uris": client_redirect_uris if bridge_relay else [current_redirect_uri], + "grant_types": grant_types or (["authorization_code", "refresh_token"] if bridge_relay else []), + "response_types": response_types or (["code"] if bridge_relay else []), + "token_endpoint_auth_method": token_endpoint_auth_method or ("none" if bridge_relay else ""), } headers = { "Content-Type": "application/json", @@ -653,20 +1206,32 @@ async def register_client_with_server( } async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Register) - response = await async_client.post( - mcp_server.registration_url, - headers=headers, - json=register_data, - ) + try: + response = await async_client.post( + mcp_server.registration_url, + headers=headers, + json=register_data, + ) + if response is not None: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + status_code, detail = dcr_fault_detail( + classify_upstream_dcr_rejection(exc.response, log_context=mcp_server.server_id) + ) + raise HTTPException(status_code=status_code, detail=detail) from exc if response is None: raise HTTPException( status_code=502, detail="MCP upstream registration endpoint returned no response", ) - response.raise_for_status() token_response = response.json() + if persist_credentials and not bridge_relay: + persistence_result = await _persist_dcr_client_registration(mcp_server, token_response, current_redirect_uri) + if persistence_result == "reused": + return dummy_return + return JSONResponse(token_response) @@ -834,17 +1399,19 @@ async def callback( error_description, ) if state: + encoded_state = _resolve_encoded_oauth_state(request, state) try: - state_data = decode_state_hash(state) + state_data = decode_state_hash(encoded_state) original_state = state_data.get("original_state") redirect_uri = _get_validated_client_redirect_uri(request, state_data) - except HTTPException: - # Untrusted/invalid client redirect_uri — surface inline rather - # than blindly forwarding the error to an attacker-controlled URL. - return _render_oauth_error_html(error, error_description) except Exception: - # State could not be decrypted (expired key, tampered, etc.). - return _render_oauth_error_html(error, error_description) + # Untrusted/invalid client redirect_uri (HTTPException), or an + # undecryptable state (expired key, tampered): surface the IdP + # error inline rather than forwarding it to an attacker-controlled + # URL, and drop the one-time cookie we can no longer consume. + response = _render_oauth_error_html(error, error_description) + _clear_oauth_state_cookie(response, request, state) + return response params: Dict[str, str] = {"error": error} if error_description: @@ -854,7 +1421,9 @@ async def callback( if original_state is not None: params["state"] = original_state complete_returned_url = _append_query_params(redirect_uri, params) - return RedirectResponse(url=complete_returned_url, status_code=302) + response = RedirectResponse(url=complete_returned_url, status_code=302) + _clear_oauth_state_cookie(response, request, state) + return response # No state — nothing to round-trip to. Show the user the error. return _render_oauth_error_html(error, error_description) @@ -870,7 +1439,8 @@ async def callback( # 3. Successful authorization response. try: - state_data = decode_state_hash(state) + encoded_state = _resolve_encoded_oauth_state(request, state) + state_data = decode_state_hash(encoded_state) original_state = state_data["original_state"] # Re-validate the client redirect URI at the sink. /authorize @@ -881,16 +1451,33 @@ async def callback( # states while permitting same-origin / allowlisted clients. redirect_uri = _get_validated_client_redirect_uri(request, state_data) - params = {"code": code, "state": original_state} + # Interactive dcr_bridge oauth_delegate: the state carries the litellm user the authorize step + # captured. Instead of forwarding the raw upstream code (which the client would present at the + # token endpoint with no way to prove who signed in), seal the user and the upstream code into a + # gateway authorization code and forward THAT. The token endpoint decrypts it to bind the + # envelope to this user. Every other flow forwards the raw code unchanged. + litellm_user_id = state_data.get("litellm_user_id") + mcp_server_id = state_data.get("mcp_server_id") + forwarded_code = code + if isinstance(litellm_user_id, str) and litellm_user_id and isinstance(mcp_server_id, str) and mcp_server_id: + forwarded_code = seal_bridge_authorization_code( + upstream_code=code, litellm_user_id=litellm_user_id, mcp_server_id=mcp_server_id + ) + + params = {"code": forwarded_code, "state": original_state} complete_returned_url = _append_query_params(redirect_uri, params) - return RedirectResponse(url=complete_returned_url, status_code=302) + response = RedirectResponse(url=complete_returned_url, status_code=302) + _clear_oauth_state_cookie(response, request, state) + return response except HTTPException: # Re-raise so a non-loopback base_url surfaces as 400 instead of # a generic "authentication incomplete" redirect. raise except Exception: - return HTMLResponse("Authentication incomplete. You can close this window.") + response = HTMLResponse("Authentication incomplete. You can close this window.") + _clear_oauth_state_cookie(response, request, state) + return response # ------------------------------ @@ -1030,11 +1617,15 @@ async def _build_oauth_protected_resource_response( """ Build OAuth protected resource response with the appropriate URL pattern. - For pass-through MCP servers (``MCPServer.is_oauth_passthrough``), the - gateway proxies the upstream's own ``oauth-protected-resource`` metadata - so that standards-compliant MCP clients discover the **upstream** IdP - instead of the gateway. The ``resource`` field is rewritten to the - gateway's own URL so clients present the bearer token back to the gateway. + For pass-through MCP servers, the gateway proxies the upstream's own + ``oauth-protected-resource`` metadata so standards-compliant MCP clients + discover the **upstream** IdP instead of the gateway. For ``true_passthrough`` + and ``oauth_delegate`` the metadata is returned verbatim (``resource`` stays + the upstream): the caller's token is forwarded to and validated by the + upstream, so its audience must be the upstream — rewriting it to the gateway + would make a strict IdP (e.g. Entra) refuse to mint it or the upstream reject + it. Only the legacy ``is_oauth_passthrough`` opt-in rewrites ``resource`` to + the gateway's own URL so clients present the bearer token back to the gateway. Args: request: FastAPI Request object @@ -1073,9 +1664,18 @@ async def _build_oauth_protected_resource_response( else: resource_url = f"{request_base_url}/mcp" + if mcp_server is not None and mcp_server_name and mcp_server.is_dcr_bridge: + return { + "authorization_servers": [f"{request_base_url}/{mcp_server_name}"], + "resource": resource_url, + "scopes_supported": (mcp_server.scopes if mcp_server.scopes else []), + } + # Pass-through branch: proxy the upstream's own metadata so discovery # directs the client at the real IdP (Okta, Keycloak, …) instead of us. - if mcp_server is not None and mcp_server.is_oauth_passthrough: + if mcp_server is not None and ( + mcp_server.is_oauth_passthrough or mcp_server.is_oauth_delegate or mcp_server.is_true_passthrough + ): try: upstream_metadata = await fetch_upstream_oauth_protected_resource(mcp_server) except Exception as exc: @@ -1091,8 +1691,9 @@ async def _build_oauth_protected_resource_response( ) if upstream_metadata is not None: - response = {**upstream_metadata, "resource": resource_url} - return response + if mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate: + return upstream_metadata + return {**upstream_metadata, "resource": resource_url} # Upstream responded but with non-200 or non-dict payload. For # pass-through servers the gateway is NOT the authorization server, @@ -1106,7 +1707,14 @@ async def _build_oauth_protected_resource_response( detail=(f"Upstream oauth-protected-resource metadata unavailable for MCP server {mcp_server.name!r}"), ) - _raise_unless_oauth2_discovery_server(mcp_server, mcp_server_name, "not an OAuth-protected resource") + obo_response = _obo_protected_resource_response(mcp_server, resource_url) + if obo_response is not None: + return obo_response + + # An OBO server with no configured issuer falls through to the gateway default so discovery still + # returns metadata; every other non-oauth2 named server 404s to avoid enumeration. + if mcp_server is None or mcp_server.auth_type != MCPAuth.oauth2_token_exchange: + _raise_unless_oauth2_discovery_server(mcp_server, mcp_server_name, "not an OAuth-protected resource") return { "authorization_servers": [ @@ -1117,6 +1725,51 @@ async def _build_oauth_protected_resource_response( } +def _obo_protected_resource_response(mcp_server: Optional[MCPServer], resource_url: str) -> Optional[dict]: + """The OBO (token_exchange) PRM, or None when this server is not OBO / no issuer is configured. + + The client SSOs with the IdP to obtain a subject token, which LiteLLM then exchanges, so discovery + points at the JWT-auth issuer(s) LiteLLM trusts (the same IdP that issues and validates the + subject), not the gateway. None falls the caller back to the gateway default so discovery still + returns metadata; it just can't name the IdP. + """ + if mcp_server is None or mcp_server.auth_type != MCPAuth.oauth2_token_exchange: + return None + issuers = _jwt_auth_issuers() + if not issuers: + return None + return { + "authorization_servers": issuers, + "resource": resource_url, + "scopes_supported": (mcp_server.scopes if mcp_server.scopes else []), + } + + +def _jwt_auth_issuers() -> list: + """The OAuth issuer identifier(s) LiteLLM's JWT auth trusts, for the OBO PRM authorization_servers. + + In token_exchange the IdP that issues the subject JWT is the same one LiteLLM validates it + against, so OBO discovery points clients at the JWT-auth issuer to obtain a subject token. + Sourced from ``JWT_ISSUER`` and any configured ``litellm_jwtauth.issuers``. + """ + import os # noqa: PLC0415 + + from litellm.proxy.proxy_server import general_settings # noqa: PLC0415 + + issuers: list = [] + env_issuer = os.getenv("JWT_ISSUER") + if env_issuer: + issuers.append(env_issuer) + + jwtauth = general_settings.get("litellm_jwtauth") if isinstance(general_settings, dict) else None + raw_issuers = jwtauth.get("issuers") if isinstance(jwtauth, dict) else getattr(jwtauth, "issuers", None) + for cfg in raw_issuers or []: + issuer = cfg.get("issuer") if isinstance(cfg, dict) else getattr(cfg, "issuer", None) + if issuer and issuer not in issuers: + issuers.append(issuer) + return issuers + + # Standard MCP pattern: /.well-known/oauth-protected-resource/mcp/{server_name} # This is the pattern expected by standard MCP clients (mcp-inspector, VSCode Copilot) @router.get( @@ -1347,6 +2000,7 @@ async def register_client(request: Request, mcp_server_name: Optional[str] = Non response_types=data.get("response_types", []), token_endpoint_auth_method=data.get("token_endpoint_auth_method", ""), fallback_client_id=resolved.server_name or resolved.name, + client_redirect_uris=data.get("redirect_uris"), ) return dummy_return @@ -1361,4 +2015,5 @@ async def register_client(request: Request, mcp_server_name: Optional[str] = Non response_types=data.get("response_types", []), token_endpoint_auth_method=data.get("token_endpoint_auth_method", ""), fallback_client_id=mcp_server_name, + client_redirect_uris=data.get("redirect_uris"), ) diff --git a/litellm/proxy/_experimental/mcp_server/exceptions.py b/litellm/proxy/_experimental/mcp_server/exceptions.py index b3f7ca9bbe2..3e3e549008d 100644 --- a/litellm/proxy/_experimental/mcp_server/exceptions.py +++ b/litellm/proxy/_experimental/mcp_server/exceptions.py @@ -73,3 +73,18 @@ class MCPUpstreamAuthError(Exception): detail=detail, headers={"www-authenticate": challenge} if challenge else None, ) + + +class MCPToolResultError(Exception): + """An MCP tool call completed with ``isError=True`` in its result. + + Never raised on the wire path: streamable HTTP MCP correctly returns tool + failures as HTTP 200 with ``result.isError: true`` per the MCP spec. This + exception only drives the standard failure logging (``status="failure"`` + payload, OTel ERROR span) for such results. + + Lives here rather than ``utils.py`` deliberately: tests reload ``utils`` + to re-read its env-derived constants, and a reload would fork this class + into two identities, breaking ``isinstance`` checks against instances + created before the reload. + """ diff --git a/litellm/proxy/_experimental/mcp_server/faults/__init__.py b/litellm/proxy/_experimental/mcp_server/faults/__init__.py new file mode 100644 index 00000000000..da078f0e242 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/faults/__init__.py @@ -0,0 +1,38 @@ +"""Typed fault values for upstream OAuth/DCR failures (phase 1 of the MCP error-handling framework). + +The invariant this package exists to enforce: an upstream failure is classified ONCE into a single +fault value, and the response status, wire error code, and prose are all derived from that value. +Deriving all three from one classification makes contradictory pairings (a caller-fault error code on +a server-fault status) unrepresentable, and gives the trust-boundary rule one enforcement point: +spec-defined machine fields may cross to callers, upstream prose and raw bodies go to server logs. +""" + +from litellm.proxy._experimental.mcp_server.faults.classify import ( + classify_upstream_dcr_rejection, + classify_upstream_token_rejection, +) +from litellm.proxy._experimental.mcp_server.faults.render_oauth import ( + dcr_fault_detail, + render_token_fault, +) +from litellm.proxy._experimental.mcp_server.faults.types import ( + CallerRejected, + CredentialSource, + GatewayRejected, + UpstreamOAuthFault, + UpstreamProtocolFault, + UpstreamReportedFault, +) + +__all__ = [ + "CallerRejected", + "CredentialSource", + "GatewayRejected", + "UpstreamOAuthFault", + "UpstreamProtocolFault", + "UpstreamReportedFault", + "classify_upstream_dcr_rejection", + "classify_upstream_token_rejection", + "dcr_fault_detail", + "render_token_fault", +] diff --git a/litellm/proxy/_experimental/mcp_server/faults/classify.py b/litellm/proxy/_experimental/mcp_server/faults/classify.py new file mode 100644 index 00000000000..8b3a09f8d8d --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/faults/classify.py @@ -0,0 +1,133 @@ +"""The single place that reads upstream OAuth/DCR failure responses. + +Every accessor here is total: an upstream that lies about its content encoding, sends an undecodable +body, or omits the spec fields yields a classified fault, never an exception. Nothing outside this +module should touch a failed upstream response's body. +""" + +from __future__ import annotations + +import httpx + +from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.faults.types import ( + GATEWAY_CAPABILITY_CODES, + GATEWAY_CREDENTIAL_CODES, + MAX_WIRE_FIELD_CHARS, + CallerRejected, + CredentialSource, + GatewayRejected, + UpstreamOAuthFault, + UpstreamProtocolFault, + UpstreamReportedFault, +) + + +def _safe_text(response: httpx.Response) -> str: + try: + return response.text + except Exception: + return "" + + +def _safe_json(response: httpx.Response) -> object: + try: + return response.json() + except Exception: + return None + + +def _bounded_field(value: object) -> str | None: + if not isinstance(value, str) or not value: + return None + return value[:MAX_WIRE_FIELD_CHARS] + + +def _log_out_of_contract(endpoint_kind: str, response: httpx.Response, log_context: str) -> None: + verbose_logger.warning( + "MCP upstream %s endpoint (%s) returned HTTP %s outside the OAuth error contract (first %s chars): %s", + endpoint_kind, + log_context, + response.status_code, + MAX_WIRE_FIELD_CHARS, + _safe_text(response)[:MAX_WIRE_FIELD_CHARS], + ) + + +def _classify_oauth_error_code( + code: str, + description: str | None, + error_uri: str | None, + credential_source: CredentialSource, + log_context: str, +) -> UpstreamOAuthFault: + """Blame assignment for a contract-conformant OAuth error code, shared by the token and DCR + classifiers. Codes by which the upstream blames itself keep that blame; ``invalid_target`` is a + gateway capability gap (RFC 8707 resource indicators, LIT-4339) no matter whose credentials were + presented; credential-indicting codes follow the credential source; everything else, including + codes we do not recognize, is the caller's to act on. The upstream's HTTP status is deliberately + never consulted: status derives from this classification at render time, which is what keeps + status and code from contradicting each other.""" + if code == "server_error" or code == "temporarily_unavailable": + return UpstreamReportedFault(code=code) + if code in GATEWAY_CAPABILITY_CODES: + verbose_logger.warning( + "MCP server %s: the upstream authorization server rejected the request with " + "invalid_target; it may require RFC 8707 resource indicators, which the gateway " + "does not send yet (tracked as LIT-4339)", + log_context, + ) + return GatewayRejected(code=code) + if credential_source == "gateway_stored" and code in GATEWAY_CREDENTIAL_CODES: + verbose_logger.warning( + "MCP server %s: upstream authorization server rejected the gateway's configured client " + "credentials (%s): %s", + log_context, + code, + description or "", + ) + return GatewayRejected(code=code) + return CallerRejected(code=code, description=description, error_uri=error_uri) + + +def classify_upstream_token_rejection( + response: httpx.Response, + credential_source: CredentialSource, + log_context: str, +) -> UpstreamOAuthFault: + """Classify a token-endpoint rejection into exactly one fault: a body with an RFC 6749 §5.2 + ``error`` field goes through blame assignment (:func:`_classify_oauth_error_code`); anything + without a usable ``error`` field is an upstream protocol fault.""" + parsed = _safe_json(response) + fields = parsed if isinstance(parsed, dict) else {} + code = _bounded_field(fields.get("error")) + if code is None: + _log_out_of_contract("token", response, log_context) + return UpstreamProtocolFault(note=f"upstream token endpoint returned HTTP {response.status_code}") + return _classify_oauth_error_code( + code, + description=_bounded_field(fields.get("error_description")), + error_uri=_bounded_field(fields.get("error_uri")), + credential_source=credential_source, + log_context=log_context, + ) + + +def classify_upstream_dcr_rejection(response: httpx.Response, log_context: str) -> UpstreamOAuthFault: + """Classify a dynamic-client-registration rejection. RFC 7591 §3.2.2 errors carry + ``error`` / ``error_description`` and go through the same blame assignment as token errors + (registration sends no client credentials, so credential codes stay caller-actionable); anything + without a usable ``error`` field is an upstream protocol fault.""" + parsed = _safe_json(response) + fields = parsed if isinstance(parsed, dict) else {} + code = _bounded_field(fields.get("error")) + if code is None: + _log_out_of_contract("registration", response, log_context) + return UpstreamProtocolFault(note=f"upstream registration failed with HTTP {response.status_code}") + return _classify_oauth_error_code( + code, + description=_bounded_field(fields.get("error_description")), + error_uri=None, + credential_source="caller_supplied", + log_context=log_context, + ) diff --git a/litellm/proxy/_experimental/mcp_server/faults/render_oauth.py b/litellm/proxy/_experimental/mcp_server/faults/render_oauth.py new file mode 100644 index 00000000000..89ce5011830 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/faults/render_oauth.py @@ -0,0 +1,89 @@ +"""Render upstream OAuth/DCR faults onto the wire. The only place that chooses statuses and bodies +for these faults, so every consumer emits the same contract: RFC 6749 §5.2-shaped JSON with the §5.1 +no-store headers on token endpoints, HTTPException details on registration. Status, code, and prose +all derive from the fault tag; exhaustive matches keep a new fault arm from shipping unrendered. +""" + +from __future__ import annotations + +from fastapi.responses import JSONResponse +from typing_extensions import assert_never + +from litellm.proxy._experimental.mcp_server.faults.types import UpstreamOAuthFault +from litellm.proxy._experimental.mcp_server.oauth_utils import TOKEN_NO_CACHE_HEADERS + + +def _gateway_rejected_description(code: str) -> str: + if code == "invalid_target": + return ( + "the upstream authorization server rejected the request (invalid_target); " + "it may require RFC 8707 resource indicators, which the gateway does not send yet" + ) + return ( + f"the upstream authorization server rejected the gateway's configured client credentials " + f"({code}); verify the MCP server's client_id and client_secret" + ) + + +def _upstream_reported_status_and_description(code: str) -> tuple[int, str]: + if code == "temporarily_unavailable": + return 503, "the upstream authorization server is temporarily unavailable; retry shortly" + return 502, "the upstream authorization server reported an internal error" + + +def render_token_fault(fault: UpstreamOAuthFault) -> JSONResponse: + """RFC 6749 §5.2 response for a token-endpoint fault. Caller-actionable rejections relay the + upstream's code on the status that code implies (401 for invalid_client per §5.2, else 400); + gateway-side faults are 502 ``server_error`` with gateway-authored prose so a caller is never + blamed for, or shown the internals of, a failure only the operator can fix.""" + match fault.tag: + case "caller_rejected": + content = { + "error": fault.code, + **({"error_description": fault.description} if fault.description else {}), + **({"error_uri": fault.error_uri} if fault.error_uri else {}), + } + status_code = 401 if fault.code == "invalid_client" else 400 + return JSONResponse(status_code=status_code, content=content, headers=TOKEN_NO_CACHE_HEADERS) + case "gateway_rejected": + return JSONResponse( + status_code=502, + content={ + "error": "server_error", + "error_description": _gateway_rejected_description(fault.code), + }, + headers=TOKEN_NO_CACHE_HEADERS, + ) + case "upstream_reported_fault": + status_code, description = _upstream_reported_status_and_description(fault.code) + return JSONResponse( + status_code=status_code, + content={"error": fault.code, "error_description": description}, + headers=TOKEN_NO_CACHE_HEADERS, + ) + case "upstream_protocol_fault": + return JSONResponse( + status_code=502, + content={"error": "server_error", "error_description": fault.note}, + headers=TOKEN_NO_CACHE_HEADERS, + ) + case _: + assert_never(fault.tag) + + +def dcr_fault_detail(fault: UpstreamOAuthFault) -> tuple[int, str]: + """Status and detail string for a registration fault, raised as HTTPException by the caller. + RFC 7591 §3.2.2 defines registration errors as 400, so a contract-conformant rejection is 400 + regardless of the status the upstream chose; everything else is a 502 upstream fault.""" + match fault.tag: + case "caller_rejected": + detail = f"{fault.code}: {fault.description}" if fault.description else fault.code + return 400, detail + case "gateway_rejected": + return 502, _gateway_rejected_description(fault.code) + case "upstream_reported_fault": + return _upstream_reported_status_and_description(fault.code) + case "upstream_protocol_fault": + return 502, fault.note + case _: + assert_never(fault.tag) diff --git a/litellm/proxy/_experimental/mcp_server/faults/types.py b/litellm/proxy/_experimental/mcp_server/faults/types.py new file mode 100644 index 00000000000..128b5e3e6cf --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/faults/types.py @@ -0,0 +1,79 @@ +"""Fault taxonomy for upstream OAuth token and DCR registration failures. + +Each fault is a frozen model on a ``tag`` literal. The tag alone decides the HTTP status, the wire +error code, and whose prose the caller sees, so those three facts can never disagree the way they can +when an upstream's status and error code are relayed independently. +""" + +from __future__ import annotations + +from typing import Literal, TypeAlias + +from pydantic import BaseModel, ConfigDict + +MAX_WIRE_FIELD_CHARS = 500 +"""Bound on every upstream-derived string that crosses to a caller or into a log line.""" + +CredentialSource: TypeAlias = Literal["gateway_stored", "caller_supplied"] +"""Whose client credentials the gateway presented upstream: the MCP server's stored configuration or +credentials the caller supplied on the request. Decides whether a credential rejection is the +caller's problem to fix or the gateway operator's.""" + +GATEWAY_CREDENTIAL_CODES: frozenset[str] = frozenset({"invalid_client", "unauthorized_client"}) +"""RFC 6749 error codes that indict the OAuth client's credentials or grant authorization. When the +gateway presented its own stored credentials, these are gateway-side faults the caller cannot act on; +when the caller supplied the credentials, they are the caller's to fix.""" + +GATEWAY_CAPABILITY_CODES: frozenset[str] = frozenset({"invalid_target"}) +"""Codes that indict a gateway capability regardless of whose credentials were presented: +``invalid_target`` means the upstream wants RFC 8707 resource indicators, which the gateway does not +send yet (LIT-4339). Never the caller's fault.""" + +UPSTREAM_FAULT_CODES: frozenset[str] = frozenset({"server_error", "temporarily_unavailable"}) +"""Codes by which the upstream blames itself. Relaying them as caller faults would invert blame, so +they classify as upstream-reported faults and render on the 5xx their meaning implies.""" + + +class CallerRejected(BaseModel): + """The upstream spoke the OAuth error contract and the failure is actionable by our caller + (e.g. ``invalid_grant``: re-run authorization). The code and its bounded prose relay on the + 4xx status the code itself implies.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["caller_rejected"] = "caller_rejected" + code: str + description: str | None = None + error_uri: str | None = None + + +class GatewayRejected(BaseModel): + """The upstream rejected the request for a cause only the gateway operator can address: the + server's stored client credentials or a gateway capability gap. Not actionable by the caller: + rendered as 502 with gateway-authored prose naming the code; the upstream's prose goes to + server logs only.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["gateway_rejected"] = "gateway_rejected" + code: str + + +class UpstreamReportedFault(BaseModel): + """The upstream blamed itself in the OAuth vocabulary. Rendered on the 5xx the code implies + (``server_error`` 502, ``temporarily_unavailable`` 503) so blame and status agree.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["upstream_reported_fault"] = "upstream_reported_fault" + code: Literal["server_error", "temporarily_unavailable"] + + +class UpstreamProtocolFault(BaseModel): + """The upstream broke the error contract: no JSON ``error`` field, an undecodable body, or a + success response without a usable token. Rendered as 502 with a gateway-authored note; the + upstream body never crosses to the caller.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["upstream_protocol_fault"] = "upstream_protocol_fault" + note: str + + +UpstreamOAuthFault: TypeAlias = CallerRejected | GatewayRejected | UpstreamReportedFault | UpstreamProtocolFault diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 95d00554034..e6e265abb61 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -13,10 +13,12 @@ import json import os import re import time -from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Union, cast +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Callable, Literal, Optional, Union, cast from urllib.parse import urlparse import anyio +import httpx from fastapi import HTTPException from httpx import HTTPStatusError from mcp import ReadResourceResult, Resource @@ -55,7 +57,11 @@ from litellm.proxy._experimental.mcp_server.elicitation_handler import ( from litellm.proxy._experimental.mcp_server.sampling_handler import ( MCP_SAMPLING_AVAILABLE, ) -from litellm.proxy._experimental.mcp_server.oauth2_token_cache import resolve_mcp_auth +from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( + MCPPerUserTokenCache, + mcp_per_user_token_cache, + resolve_mcp_auth, +) from litellm.proxy._experimental.mcp_server.outbound_credentials import ( Error, Ok, @@ -63,15 +69,25 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import ( ) from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( raise_public, + raise_token_exchange_challenge, raise_user_oauth_challenge, to_server_spec, to_subject, ) +from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import ( + InvalidatableOAuthTokenStore, +) from litellm.proxy._experimental.mcp_server.outbound_credentials.per_user_oauth_store import ( LazyPerUserOAuthTokenStore, ) +from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchange_provider import ( + build_token_exchanger, +) from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AuthorizationCodeConfig, + PassthroughConfig, + ServerSpec, + TokenExchangeConfig, ) from litellm.proxy._experimental.mcp_server.utils import ( MCP_TOOL_PREFIX_SEPARATOR, @@ -104,10 +120,10 @@ from litellm.proxy._types import ( from litellm.proxy.auth.ip_address_utils import IPAddressUtils from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper from litellm.proxy.common_utils.user_api_key_cache import get_management_object_ttl -from litellm.proxy.utils import ProxyLogging +from litellm.proxy.utils import ProxyLogging, get_server_root_path from litellm.repositories.table_repositories import MCPServerRepository from litellm.types.llms.custom_http import httpxSpecialProvider -from litellm.types.mcp import MCPAuth, MCPStdioConfig +from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE, MCPAuth, MCPStdioConfig from litellm.types.mcp_server.mcp_server_manager import ( MCPInfo, MCPOAuthMetadata, @@ -155,10 +171,47 @@ _AZURE_ENTRA_HOSTS = { # BYOK credential cache. Keyed by (user_id, server_id); value is # (values_dict, monotonic_timestamp). Keeps the tool-call and tool-listing # paths off the DB on every request within the TTL window. -_user_env_vars_cache: Dict[Tuple[str, str], Tuple[Dict[str, str], float]] = {} +_user_env_vars_cache: dict[tuple[str, str], tuple[dict[str, str], float]] = {} _USER_ENV_VARS_CACHE_TTL = 60 # seconds _USER_ENV_VARS_CACHE_MAX_SIZE = 4096 # cap to prevent unbounded growth +# Auth types whose upstream OAuth endpoints (protected-resource + authorization-server metadata) the +# gateway discovers from the upstream itself: interactive oauth2 and the two client-forwarded modes. +# OBO/M2M endpoint discovery is decided separately via _obo_needs_endpoint_discovery. Shared by the +# config-YAML and DB server loaders so the two paths cannot drift on which modes trigger discovery. +_UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: tuple[MCPAuth, ...] = ( + MCPAuth.oauth2, + MCPAuth.true_passthrough, + MCPAuth.oauth_delegate, +) + + +def _carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_server: MCPServer | None) -> None: + """Keep the last known good OAuth endpoints when a rebuild's re-discovery comes back empty. + + A rebuild wholesale-replaces the registry entry, so without this a transient upstream outage + during re-discovery downgrades a working server (``authorization_url`` set) to a broken one + (``None``, /authorize 400s) with no configuration change. Mirrors the ``short_prefix`` + carry-forward. Skipped when the server's ``url`` or ``auth_type`` changed, since the previous + endpoints may then belong to a different upstream. ``registration_url`` IS carried here even + though ``_persist_discovered_oauth_endpoints`` refuses to write it to the row: carrying only + restores the same in-memory value the previous build already ran with, while persisting it + would flip ``_dcr_bridge_relays_client_registration`` (which keys off the stored column) for + dcr_bridge servers that never had one configured. + """ + if previous_server is None: + return + if previous_server.url != new_server.url or previous_server.auth_type != new_server.auth_type: + return + if new_server.authorization_url is None and previous_server.authorization_url: + new_server.authorization_url = previous_server.authorization_url + if new_server.token_url is None and previous_server.token_url: + new_server.token_url = previous_server.token_url + if new_server.registration_url is None and previous_server.registration_url: + new_server.registration_url = previous_server.registration_url + if not new_server.scopes and previous_server.scopes: + new_server.scopes = previous_server.scopes + def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None: """Drop a cached entry after the user stores or clears their env var values @@ -166,7 +219,7 @@ def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None: _user_env_vars_cache.pop((user_id, server_id), None) -def _write_user_env_vars_cache(user_id: str, server_id: str, values: Dict[str, str]) -> None: +def _write_user_env_vars_cache(user_id: str, server_id: str, values: dict[str, str]) -> None: cache_key = (user_id, server_id) # Re-insert at the tail so eviction drops the oldest-written entry, not a # freshly refreshed one, and only sheds a single entry instead of wiping the @@ -179,7 +232,7 @@ def _write_user_env_vars_cache(user_id: str, server_id: str, values: Dict[str, s def _should_strip_caller_authorization( mcp_server: MCPServer, - raw_headers: Optional[Dict[str, str]], + raw_headers: Optional[dict[str, str]], user_api_key_auth: Optional[UserAPIKeyAuth], ) -> bool: """Decide whether the caller's ``Authorization`` header must NOT be @@ -205,7 +258,18 @@ def _should_strip_caller_authorization( pass-through cold-start case (RFC 9728) the bearer in ``Authorization`` is the upstream OAuth token and must be forwarded, so we keep it. + - **oauth_delegate servers**: admission always runs and there is no + anonymous path, so the caller's separate ``Authorization`` is + forwarded only when a distinct ``x-litellm-api-key`` carried + admission. Without that header the ``Authorization`` *was* the + admission credential — a virtual key, an IdP JWT, or an SSO / OIDC / + session token whose ``api_key`` is ``None`` — and must never reach + the upstream, so it is stripped regardless of the ``api_key`` value. """ + if mcp_server.auth_type == MCPAuth.oauth2_token_exchange: + # OBO: the inbound Authorization is the subject token. It is exchanged at the IdP and only the + # exchanged token is sent upstream, so the raw caller bearer must never be forwarded. + return True if mcp_server.has_client_credentials: return True if mcp_server.auth_type == MCPAuth.oauth2 and to_server_spec(mcp_server) is not None: @@ -214,11 +278,13 @@ def _should_strip_caller_authorization( # upstream — it would override another user's stored credential. Delegate and # pass-through return None from to_server_spec and keep forwarding the bearer. return True - if not mcp_server.is_oauth_passthrough: + if not (mcp_server.is_oauth_passthrough or mcp_server.is_oauth_delegate): return False normalized_raw_headers = {str(k).lower(): v for k, v in (raw_headers or {}).items() if isinstance(k, str)} has_explicit_litellm_admission_header = normalized_raw_headers.get("x-litellm-api-key") is not None + if mcp_server.is_oauth_delegate: + return not has_explicit_litellm_admission_header admission_consumed_authorization_as_litellm_key = ( user_api_key_auth is not None and bool(getattr(user_api_key_auth, "api_key", None)) @@ -241,9 +307,162 @@ def _without_authorization( return filtered or None +def _format_byok_openapi_auth_header(mcp_server: MCPServer, mcp_auth_header: str) -> str: + """Format a raw BYOK credential for OpenAPI tool ``Authorization`` injection.""" + if mcp_server.auth_type == MCPAuth.api_key: + return f"ApiKey {mcp_auth_header}" + if mcp_server.auth_type == MCPAuth.basic: + return f"Basic {mcp_auth_header}" + return f"Bearer {mcp_auth_header}" + + +def _openapi_forwarded_extra_headers( + mcp_server: MCPServer, + raw_headers: Optional[dict[str, str]], + user_api_key_auth: Optional[UserAPIKeyAuth], +) -> Optional[dict[str, str]]: + if not mcp_server.extra_headers or not raw_headers: + return None + normalized_raw = {str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str)} + skip_caller_authorization = _should_strip_caller_authorization( + mcp_server=mcp_server, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) + forwarded: dict[str, str] = {} + for header_name in mcp_server.extra_headers: + if not isinstance(header_name, str): + continue + if skip_caller_authorization and header_name.lower() == "authorization": + continue + value = normalized_raw.get(header_name.lower()) + if value is not None: + forwarded[header_name] = value + return forwarded or None + + +async def _resolve_byok_mcp_auth_header( + mcp_server: MCPServer, + user_api_key_auth: Optional[UserAPIKeyAuth], + mcp_auth_header: Optional[str], +) -> Optional[str]: + """Resolve BYOK credential for tool calls that bypass ``execute_mcp_tool``.""" + if not mcp_server.is_byok: + return mcp_auth_header + + from litellm.proxy._experimental.mcp_server.server import ( + _check_byok_credential, + _get_byok_credential, + ) + + if not mcp_auth_header: + byok_cred = await _get_byok_credential(mcp_server, user_api_key_auth) + if byok_cred is None: + raise HTTPException( + status_code=401, + detail={ + "error": "byok_auth_required", + "server_id": mcp_server.server_id, + "server_name": mcp_server.server_name or mcp_server.name, + "message": ( + "No stored credential found for this BYOK server. " + "Complete the OAuth authorization flow to provide your API key." + ), + }, + headers={"WWW-Authenticate": 'Bearer resource_metadata="/.well-known/oauth-protected-resource"'}, + ) + return byok_cred + + await _check_byok_credential(mcp_server, user_api_key_auth) + return mcp_auth_header + + +def _client_forwarded_authorization_headers( + mcp_server: MCPServer, + oauth2_headers: Optional[dict[str, str]], + raw_headers: Optional[dict[str, str]], + user_api_key_auth: Optional[UserAPIKeyAuth], +) -> Optional[dict[str, str]]: + """Egress headers for the client-forwarded-token modes (``true_passthrough`` / ``oauth_delegate``). + + Forwards the caller's ``Authorization`` to the upstream, stripped when + ``_should_strip_caller_authorization`` says it was consumed as the LiteLLM admission key. Shared by + ``_call_regular_mcp_tool`` and ``server.py``'s ``_prepare_mcp_server_headers`` so the two egress + paths cannot drift, mirroring the ``_should_strip_caller_authorization`` split. + """ + extra_headers = oauth2_headers.copy() if oauth2_headers else None + if extra_headers and _should_strip_caller_authorization( + mcp_server=mcp_server, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ): + return _without_authorization(extra_headers) + return extra_headers + + +def _take_forwarded_authorization( + headers: Optional[dict[str, str]], +) -> tuple[Optional[str], Optional[dict[str, str]]]: + """Pop the ``Authorization`` value out of ``headers`` (case-insensitive), returning it with the + remaining headers, so the passthrough resolver arm is the single Authorization source rather than + the header also riding in ``extra_headers`` (which the resolved auth would then defer to).""" + if not headers: + return None, headers + value = next((v for k, v in headers.items() if k.lower() == "authorization"), None) + return value, _without_authorization(headers) + + +def _passthrough_token_from_mcp_auth_header( + mcp_auth_header: Optional[Union[str, dict[str, str]]], +) -> Optional[str]: + """The caller's per-server upstream credential for a passthrough-mode server, or None. + + Sourced from ``x-mcp-{alias}-authorization`` (string or per-header dict form) or the deprecated + global ``x-mcp-auth`` fallback. Per-server headers are the multi-server shape: they bind one + token to one server, so an aggregate scope with several passthrough-mode servers never replays + a single credential across upstreams. The value is forwarded verbatim, so it must be the full + header value (e.g. ``Bearer ``).""" + if isinstance(mcp_auth_header, str): + return mcp_auth_header or None + if isinstance(mcp_auth_header, dict): + return next((v for k, v in mcp_auth_header.items() if k.lower() == "authorization"), None) + return None + + +def _consumes_caller_authorization(server: MCPServer) -> bool: + """True when this server's egress forwards the caller's request-wide ``Authorization`` upstream: + the client-forwarded token modes, legacy OAuth pass-through, and legacy upstream-delegated + interactive oauth2. An unstamped oauth2 row (flow column not yet backfilled) reads as a consumer, + which errs toward suppression — the fail-safe direction.""" + if server.is_true_passthrough or server.is_oauth_delegate or server.is_oauth_passthrough: + return True + return ( + server.auth_type == MCPAuth.oauth2 + and getattr(server, "delegate_auth_to_upstream", False) is True + and not server.has_client_credentials + ) + + +def _caller_authorization_fans_out( + server: MCPServer, + scope_servers: Optional[list[MCPServer]], +) -> bool: + """True when forwarding the caller's request-wide ``Authorization`` to ``server`` inside a + listing fan-out would replay one credential against multiple upstreams: another server in the + scope also consumes it (RFC 9700 cross-resource replay). ``scope_servers`` is None for + explicitly-addressed operations (tool call, get_prompt, read_resource, single-server routes), + where the client named the one target and the gateway is not choosing recipients.""" + if scope_servers is None: + return False + return any( + other is not None and other.server_id != server.server_id and _consumes_caller_authorization(other) + for other in scope_servers + ) + + def _extract_upstream_auth_failure( exc: BaseException, -) -> Optional[Tuple[int, Optional[str]]]: +) -> Optional[tuple[int, Optional[str]]]: """Walk the exception tree looking for an HTTP 401/403 response from the upstream MCP server. @@ -255,8 +474,8 @@ def _extract_upstream_auth_failure( Returns ``(status_code, www_authenticate)`` on match, else ``None``. """ - seen: Set[int] = set() - stack: List[BaseException] = [exc] + seen: set[int] = set() + stack: list[BaseException] = [exc] while stack: current = stack.pop() if id(current) in seen: @@ -337,7 +556,7 @@ def _warn_internal_delegate_pkce_if_applicable(server: MCPServer, *, source: str ) -def _deserialize_json_dict(data: Any) -> Optional[Dict[str, str]]: +def _deserialize_json_dict(data: Any) -> Optional[dict[str, str]]: """ Deserialize optional JSON mappings stored in the database. @@ -358,7 +577,7 @@ def _deserialize_json_dict(data: Any) -> Optional[Dict[str, str]]: return data -def _deserialize_json_list(data: Any) -> Optional[List[Dict[str, Any]]]: +def _deserialize_json_list(data: Any) -> Optional[list[dict[str, Any]]]: """Deserialize a JSON array stored in the DB (``env_vars`` and friends). Returns ``None`` for empty / null / unparseable input. Accepts strings @@ -499,6 +718,21 @@ def _create_elicitation_callback(): class MCPServerManager: _STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$") + @staticmethod + def _explicit_oauth2_flow( + oauth2_flow: Optional[str], + ) -> Optional[Literal["client_credentials", "authorization_code"]]: + """DB rows persist their flow (write-time stamps plus the startup backfill) and + config servers must declare it (validated at load), so both builds read the + value verbatim: unknown or null resolves to None, which + ``needs_user_oauth_token`` already treats as interactive. Field-shape inference + survives only in the request-time security helpers (``effective_oauth2_flow`` / + ``resolve_oauth2_flow_for_request``). + """ + if oauth2_flow in ("client_credentials", "authorization_code"): + return cast(Literal["client_credentials", "authorization_code"], oauth2_flow) + return None + @staticmethod def _resolve_oauth2_flow( *, @@ -509,11 +743,18 @@ class MCPServerManager: client_id: Optional[str], client_secret: Optional[str], ) -> Optional[Literal["client_credentials", "authorization_code"]]: - """Infer oauth2_flow for legacy records that omit the field. + """Infer oauth2_flow from field shape when the value is omitted. - DB rows created before oauth2_flow support may have OAuth2 client - credentials + token_url but a null oauth2_flow. Treat these as M2M, - unless authorization_url is present (interactive OAuth). + SECURITY-SENSITIVE: this is the shape-inference engine both request-time security + helpers delegate to, so it is what decides M2M-vs-interactive for an unstamped row. + Always access it through ``effective_oauth2_flow`` (boolean/enum decisions) or + ``resolve_oauth2_flow_for_request`` (the egress object backstop), which are the single + choke points for request-time resolution; do not call it directly from security sites + and do not weaken its M2M-shape branch without accounting for those callers. DB rows + are stamped at write time and by the startup backfill, config servers must declare + oauth2_flow (validated at load), and both builds read the value verbatim via + ``_explicit_oauth2_flow``. Delete this whole request-time layer only once the backstop + warning stays silent in production. """ if oauth2_flow in ("client_credentials", "authorization_code"): return cast(Literal["client_credentials", "authorization_code"], oauth2_flow) @@ -528,12 +769,79 @@ class MCPServerManager: return "client_credentials" return None - def __init__(self, cred_provider: Optional[UpstreamCredentialProvider] = None): - self._cred_provider = cred_provider or UpstreamCredentialProvider( - oauth_token_store=LazyPerUserOAuthTokenStore(self.get_mcp_server_by_id) + @staticmethod + def effective_oauth2_flow(server: "MCPServer") -> Optional[Literal["client_credentials", "authorization_code"]]: + """The oauth2_flow a security decision must use for ``server`` this request. + + Column-first, shape-fallback: a stamped row returns its explicit value; an + unstamped (null) row whose fields carry the M2M shape resolves to + ``client_credentials`` so it is treated as M2M and fails closed. Every + security-sensitive reader (anonymous-delegate allowlist and gate, egress flow + resolution) goes through this one helper rather than reading the bare + ``has_client_credentials`` column, which is unreliable for null rows. + """ + return MCPServerManager._resolve_oauth2_flow( + auth_type=server.auth_type, + oauth2_flow=server.oauth2_flow, + token_url=server.token_url, + authorization_url=server.authorization_url, + client_id=server.client_id, + client_secret=server.client_secret, ) - self.registry: Dict[str, MCPServer] = {} - self.config_mcp_servers: Dict[str, MCPServer] = {} + + @staticmethod + def resolve_oauth2_flow_for_request(server: "MCPServer") -> "MCPServer": + """Return ``server`` with its effective oauth2_flow applied, for egress paths. + + A stamped row is returned unchanged (its effective flow equals the stored value). + An unstamped M2M-shape row is returned as a per-request copy carrying + ``oauth2_flow=client_credentials`` so downstream ``has_client_credentials`` / + ``needs_user_oauth_token`` compute correctly and the stored client credentials are + used instead of forwarding the caller's Authorization. Use this at every point that + resolves an allowed server id into an ``MCPServer`` for a tool call or listing. + """ + effective = MCPServerManager.effective_oauth2_flow(server) + if effective is None or effective == server.oauth2_flow: + return server + verbose_logger.warning( + "MCP server %s has no persisted oauth2_flow but matches the %s shape; using the " + "inferred flow for this request. The startup backfill leaves this ambiguous M2M " + "shape unstamped on purpose, so it will NOT self-heal: set oauth2_flow explicitly " + "in the dashboard or via PUT /v1/mcp/server (client_credentials for M2M, or " + "authorization_code after an interactive sign-in).", + server.server_id, + effective, + ) + return server.model_copy(update={"oauth2_flow": effective}) + + @staticmethod + def _obo_needs_endpoint_discovery( + auth_type: Optional[MCPAuthType], + token_exchange_endpoint: Optional[str], + token_url: Optional[str], + ) -> bool: + """An ``oauth2_token_exchange`` server with no configured token endpoint can have it + discovered (RFC 9728 -> RFC 8414) the same way the ``oauth2`` flow already does; an explicitly + configured ``token_exchange_endpoint``/``token_url`` wins and skips the discovery round-trip. + """ + return auth_type == MCPAuth.oauth2_token_exchange and not (token_exchange_endpoint or token_url) + + def __init__( + self, + cred_provider: Optional[UpstreamCredentialProvider] = None, + per_user_oauth_token_store: Optional[InvalidatableOAuthTokenStore] = None, + per_user_token_cache: Optional[MCPPerUserTokenCache] = None, + ): + self._per_user_oauth_token_store = per_user_oauth_token_store or LazyPerUserOAuthTokenStore( + self.get_mcp_server_by_id + ) + self._per_user_token_cache = per_user_token_cache or mcp_per_user_token_cache + self._cred_provider = cred_provider or UpstreamCredentialProvider( + oauth_token_store=self._per_user_oauth_token_store, + token_exchanger=build_token_exchanger(), + ) + self.registry: dict[str, MCPServer] = {} + self.config_mcp_servers: dict[str, MCPServer] = {} """ eg. [ @@ -550,17 +858,24 @@ class MCPServerManager: ] """ - self.tool_name_to_mcp_server_name_mapping: Dict[str, str] = {} + # Per-server outbound tool-call concurrency limiters, lazily created from + # each server's max_concurrent_requests. Keyed by server_id so the cap + # survives the registry atomic-swap on config reload; a missing key means + # the server has no configured limit. The limit is cached alongside the + # semaphore so an edited limit rebuilds it instead of keeping the old cap + # until restart. + self._server_call_semaphores: dict[str, tuple[int, asyncio.Semaphore]] = {} + self.tool_name_to_mcp_server_name_mapping: dict[str, str] = {} """ { "gmail_send_email": "zapier_mcp_server", } """ - self._upstream_initialize_instructions_by_server_id: Dict[str, str] = {} + self._upstream_initialize_instructions_by_server_id: dict[str, str] = {} # Per-server monotonic timestamp of last upstream prefetch attempt (success, # empty result, or failure). Used to throttle re-probes for servers that do # not return instructions, and to apply a short cooldown after failures. - self._upstream_initialize_instructions_probed_at: Dict[str, float] = {} + self._upstream_initialize_instructions_probed_at: dict[str, float] = {} def _remember_upstream_initialize_instructions(self, server: MCPServer, client: MCPClient) -> None: raw = getattr(client, "_last_initialize_instructions", None) @@ -616,7 +931,7 @@ class MCPServerManager: user_api_key_auth=None, raise_on_missing=False, ) - extra_headers: Optional[Dict[str, str]] = dict(resolved_static_headers) if resolved_static_headers else None + extra_headers: Optional[dict[str, str]] = dict(resolved_static_headers) if resolved_static_headers else None client = await self._create_mcp_client( server=server, mcp_auth_header=None, @@ -636,7 +951,7 @@ class MCPServerManager: e, ) - def get_registry(self) -> Dict[str, MCPServer]: + def get_registry(self) -> dict[str, MCPServer]: """ Get the registered MCP Servers from the registry and union with the config MCP Servers """ @@ -644,8 +959,8 @@ class MCPServerManager: async def load_servers_from_config( self, - mcp_servers_config: Dict[str, Any], - mcp_aliases: Optional[Dict[str, str]] = None, + mcp_servers_config: dict[str, Any], + mcp_aliases: Optional[dict[str, str]] = None, ): """ Load the MCP Servers from the config @@ -663,7 +978,7 @@ class MCPServerManager: for server_name, server_config in mcp_servers_config.items(): validate_mcp_server_name(server_name) - _mcp_info: Dict[str, Any] = server_config.get("mcp_info", None) or {} + _mcp_info: dict[str, Any] = server_config.get("mcp_info", None) or {} # Preserve all custom fields from config while setting defaults for core fields mcp_info: MCPInfo = _mcp_info.copy() # Set default values for core fields if not present @@ -711,14 +1026,27 @@ class MCPServerManager: ) auth_type = server_config.get("auth_type", None) - if server_url and auth_type is not None and auth_type == MCPAuth.oauth2: + if server_url and ( + auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES + or self._obo_needs_endpoint_discovery( + auth_type, + server_config.get("token_exchange_endpoint"), + server_config.get("token_url"), + ) + ): mcp_oauth_metadata = await self._descovery_metadata( server_url=server_url, + allow_origin_fallback=auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, ) else: mcp_oauth_metadata = None - resolved_scopes = server_config.get("scopes") or (mcp_oauth_metadata.scopes if mcp_oauth_metadata else None) + # Filter blank scopes (e.g. YAML ``scopes: [""]``) the same way the DB-build path does, so + # an all-blank list normalizes to None rather than a ``("",)`` tuple that skips the + # entra_obo fail-closed scope precondition and POSTs an empty scope to the IdP. + resolved_scopes = self._extract_scopes(server_config.get("scopes")) or ( + mcp_oauth_metadata.scopes if mcp_oauth_metadata else None + ) resolved_authorization_url = server_config.get("authorization_url") or ( mcp_oauth_metadata.authorization_url if mcp_oauth_metadata else None ) @@ -729,6 +1057,38 @@ class MCPServerManager: mcp_oauth_metadata.registration_url if mcp_oauth_metadata else None ) + config_oauth2_flow = server_config.get("oauth2_flow", None) + if auth_type == MCPAuth.oauth2 and config_oauth2_flow not in ( + "client_credentials", + "authorization_code", + ): + raise ValueError( + f"Invalid config for MCP server '{server_name or server_id}': auth_type oauth2 " + f"requires an explicit oauth2_flow (got {config_oauth2_flow!r}). Set " + "oauth2_flow: client_credentials for machine-to-machine servers (the proxy mints " + "a shared token at token_url using client_id/client_secret, no user interaction) " + "or oauth2_flow: authorization_code for interactive servers (per-user tokens via " + "browser sign-in, including delegate_auth_to_upstream)." + ) + + config_dcr_bridge = server_config.get("dcr_bridge", None) + if config_dcr_bridge is not None and not isinstance(config_dcr_bridge, bool): + raise ValueError( + f"Invalid config for MCP server '{server_name or server_id}': dcr_bridge " + f"must be a boolean (got {config_dcr_bridge!r})." + ) + if config_dcr_bridge and auth_type not in ( + MCPAuth.true_passthrough, + MCPAuth.oauth_delegate, + ): + raise ValueError( + f"Invalid config for MCP server '{server_name or server_id}': dcr_bridge is only " + f"supported for auth_type true_passthrough or oauth_delegate (got {auth_type!r}). " + "The DCR bridge serves gateway-hosted OAuth discovery for the client-forwarded " + "token modes; interactive oauth2 servers already run the gateway " + "authorization-code flow." + ) + new_server = MCPServer( server_id=server_id, name=name_for_prefix, @@ -742,14 +1102,7 @@ class MCPServerManager: # oauth specific fields client_id=server_config.get("client_id", None), client_secret=server_config.get("client_secret", None), - oauth2_flow=self._resolve_oauth2_flow( - auth_type=auth_type, - oauth2_flow=server_config.get("oauth2_flow", None), - token_url=resolved_token_url, - authorization_url=resolved_authorization_url, - client_id=server_config.get("client_id", None), - client_secret=server_config.get("client_secret", None), - ), + oauth2_flow=self._explicit_oauth2_flow(config_oauth2_flow), scopes=resolved_scopes, authorization_url=resolved_authorization_url, token_url=resolved_token_url, @@ -771,6 +1124,7 @@ class MCPServerManager: available_on_public_internet=bool(server_config.get("available_on_public_internet", True)), delegate_auth_to_upstream=bool(server_config.get("delegate_auth_to_upstream", False)), oauth_passthrough=bool(server_config.get("oauth_passthrough", False)), + dcr_bridge=config_dcr_bridge, # AWS SigV4 fields aws_access_key_id=server_config.get("aws_access_key_id", None), aws_secret_access_key=server_config.get("aws_secret_access_key", None), @@ -785,11 +1139,13 @@ class MCPServerManager: audience=server_config.get("audience", None), subject_token_type=server_config.get( "subject_token_type", - "urn:ietf:params:oauth:token-type:access_token", + DEFAULT_SUBJECT_TOKEN_TYPE, ), + token_exchange_profile=server_config.get("token_exchange_profile", "rfc8693"), allow_sampling=bool(server_config.get("allow_sampling", False)), allow_elicitation=bool(server_config.get("allow_elicitation", False)), timeout=server_config.get("timeout", None), + max_concurrent_requests=server_config.get("max_concurrent_requests", None), ) self._assign_unique_short_prefix(new_server) _warn_internal_delegate_pkce_if_applicable(new_server, source="config") @@ -851,7 +1207,7 @@ class MCPServerManager: server_prefix = get_server_prefix(server) # Build headers from server configuration - headers: Dict[str, str] = {} + headers: dict[str, str] = {} # Add authentication headers if configured if server.authentication_token: @@ -961,7 +1317,7 @@ class MCPServerManager: openapi_key_prefix = prefix_root + MCP_TOOL_PREFIX_SEPARATOR global_mcp_tool_registry.unregister_tools_with_prefix(openapi_key_prefix) - owned_raw: Set[str] = set() + owned_raw: set[str] = set() for p in iter_known_server_prefixes(server): if p: owned_raw.add(p) @@ -970,7 +1326,7 @@ class MCPServerManager: owned_normalized = {normalize_server_name(x) for x in owned_raw} - stale_mapping_keys: List[str] = [] + stale_mapping_keys: list[str] = [] for tool_name, mapped_server in list(self.tool_name_to_mcp_server_name_mapping.items()): if mapped_server in owned_raw: stale_mapping_keys.append(tool_name) @@ -998,7 +1354,7 @@ class MCPServerManager: mcp_server: LiteLLM_MCPServerTable, *, env_vars_are_encrypted: bool, - ) -> Optional[List[Dict[str, Any]]]: + ) -> Optional[list[dict[str, Any]]]: env_vars_list = _deserialize_json_list(getattr(mcp_server, "env_vars", None)) if env_vars_are_encrypted: from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 @@ -1014,6 +1370,7 @@ class MCPServerManager: *, credentials_are_encrypted: bool = True, env_vars_are_encrypted: Optional[bool] = None, + persist_discovered_endpoints: bool = True, ) -> MCPServer: _mcp_info: MCPInfo = mcp_server.mcp_info or {} env_dict = _deserialize_json_dict(getattr(mcp_server, "env", None)) @@ -1073,7 +1430,7 @@ class MCPServerManager: # AWS SigV4 credential fields aws_creds = self._extract_aws_credentials(credentials_dict, credentials_are_encrypted) - scopes: Optional[List[str]] = None + scopes: Optional[list[str]] = None if credentials_dict: scopes_value = credentials_dict.get("scopes") if scopes_value is not None: @@ -1090,12 +1447,30 @@ class MCPServerManager: auth_type = cast(MCPAuthType, mcp_server.auth_type) server_url = mcp_server.url - needs_discovery = bool(server_url) and auth_type == MCPAuth.oauth2 and not mcp_server.authorization_url + needs_discovery = bool(server_url) and ( + (auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and not mcp_server.authorization_url) + or self._obo_needs_endpoint_discovery( + auth_type, + mcp_server.token_exchange_endpoint + or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None), + mcp_server.token_url, + ) + ) mcp_oauth_metadata = ( - await self._descovery_metadata(server_url=server_url) # type: ignore[arg-type] + await self._descovery_metadata( + server_url=server_url, # type: ignore[arg-type] + allow_origin_fallback=auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, + ) if needs_discovery else None ) + if needs_discovery and mcp_oauth_metadata is None: + verbose_logger.warning( + "MCP OAuth discovery yielded no metadata for server %s (%s); " + "OAuth endpoints stay unresolved until a rebuild succeeds", + mcp_server.server_id, + server_url, + ) resolved_scopes = scopes or (mcp_oauth_metadata.scopes if mcp_oauth_metadata else None) @@ -1115,15 +1490,7 @@ class MCPServerManager: env_vars=env_vars_list, client_id=client_id_value or getattr(mcp_server, "client_id", None), client_secret=client_secret_value or getattr(mcp_server, "client_secret", None), - oauth2_flow=self._resolve_oauth2_flow( - auth_type=auth_type, - oauth2_flow=getattr(mcp_server, "oauth2_flow", None), - token_url=mcp_server.token_url or getattr(mcp_oauth_metadata, "token_url", None), - authorization_url=mcp_server.authorization_url - or getattr(mcp_oauth_metadata, "authorization_url", None), - client_id=client_id_value or getattr(mcp_server, "client_id", None), - client_secret=client_secret_value or getattr(mcp_server, "client_secret", None), - ), + oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)), scopes=resolved_scopes, authorization_url=mcp_server.authorization_url or getattr(mcp_oauth_metadata, "authorization_url", None), token_url=mcp_server.token_url or getattr(mcp_oauth_metadata, "token_url", None), @@ -1141,6 +1508,7 @@ class MCPServerManager: available_on_public_internet=bool(getattr(mcp_server, "available_on_public_internet", True)), delegate_auth_to_upstream=bool(getattr(mcp_server, "delegate_auth_to_upstream", False)), oauth_passthrough=bool(getattr(mcp_server, "oauth_passthrough", False)), + dcr_bridge=getattr(mcp_server, "dcr_bridge", None), created_at=getattr(mcp_server, "created_at", None), updated_at=getattr(mcp_server, "updated_at", None), tool_name_to_display_name=_deserialize_json_dict(getattr(mcp_server, "tool_name_to_display_name", None)), @@ -1158,16 +1526,136 @@ class MCPServerManager: aws_role_name=aws_creds.get("aws_role_name"), aws_session_name=aws_creds.get("aws_session_name"), instructions=mcp_server.instructions, - # Token Exchange (OBO) fields — read from credentials JSON blob - token_exchange_endpoint=(credentials_dict.get("token_exchange_endpoint") if credentials_dict else None), - audience=(credentials_dict.get("audience") if credentials_dict else None), - subject_token_type=(credentials_dict.get("subject_token_type") if credentials_dict else None) - or "urn:ietf:params:oauth:token-type:access_token", + # Token exchange (OBO) fields: dedicated columns, with the credentials blob as a + # back-compat fallback for servers persisted before the columns existed. + token_exchange_endpoint=mcp_server.token_exchange_endpoint + or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None), + audience=mcp_server.audience or (credentials_dict.get("audience") if credentials_dict else None), + subject_token_type=mcp_server.subject_token_type + or (credentials_dict.get("subject_token_type") if credentials_dict else None) + or DEFAULT_SUBJECT_TOKEN_TYPE, + token_exchange_profile=mcp_server.token_exchange_profile + or (credentials_dict.get("token_exchange_profile") if credentials_dict else None) + or "rfc8693", timeout=getattr(mcp_server, "timeout", None), + max_concurrent_requests=getattr(mcp_server, "max_concurrent_requests", None), ) _warn_internal_delegate_pkce_if_applicable(new_server, source="database") + if persist_discovered_endpoints: + await self._persist_discovered_obo_token_url( + server_id=mcp_server.server_id, + auth_type=auth_type, + existing_token_url=mcp_server.token_url, + discovered_token_url=new_server.token_url, + ) + await self._persist_discovered_oauth_endpoints( + server_id=mcp_server.server_id, + auth_type=auth_type, + existing_authorization_url=mcp_server.authorization_url, + existing_token_url=mcp_server.token_url, + existing_scopes=scopes, + metadata=mcp_oauth_metadata, + ) return new_server + async def _persist_discovered_obo_token_url( + self, + *, + server_id: str, + auth_type: Optional[MCPAuthType], + existing_token_url: Optional[str], + discovered_token_url: Optional[str], + ) -> None: + """Write a freshly discovered OBO token endpoint back onto the DB row. + + ``build_mcp_server_from_table`` resolves ``token_url`` via RFC 9728 -> RFC 8414 for an + ``oauth2_token_exchange`` server that has none configured, but that resolved value otherwise + lives only on the returned in-memory object; the row keeps ``token_url=None`` so every rebuild + re-runs discovery, and a transient upstream outage during a rebuild leaves the server with no + endpoint until discovery next succeeds. Persisting it makes ``_obo_needs_endpoint_discovery`` + return False on the next build. Fires at most once per server (skipped once the row has a + value), and is best-effort: a write failure just means discovery runs again next time. + """ + if auth_type != MCPAuth.oauth2_token_exchange: + return + if existing_token_url or not discovered_token_url: + return + from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 + + if prisma_client is None: + return + try: + await MCPServerRepository(prisma_client).table.update( + where={"server_id": server_id}, + data={"token_url": discovered_token_url}, + ) + verbose_logger.debug("Persisted discovered OBO token_url for MCP server %s", server_id) + except Exception as exc: # noqa: BLE001 - best-effort; a failed write re-discovers next build + verbose_logger.warning("Failed to persist discovered OBO token_url for MCP server %s: %s", server_id, exc) + + async def _persist_discovered_oauth_endpoints( + self, + *, + server_id: str, + auth_type: MCPAuthType | None, + existing_authorization_url: str | None, + existing_token_url: str | None, + existing_scopes: list[str] | None, + metadata: MCPOAuthMetadata | None, + ) -> None: + """Write freshly discovered OAuth endpoints back onto the DB row. + + Same rationale as ``_persist_discovered_obo_token_url`` but for the interactive oauth2 + family: discovered ``authorization_url``/``token_url``/``scopes`` otherwise live only on + the in-memory registry entry, which is rebuilt on every client connect (the DCR reuse path + calls ``update_server``) and on every post-write DB reload, so one failed re-discovery + serves 400 "authorization url is not set" from /authorize until a later rebuild succeeds. + Only fills row fields that are currently empty, never persists origin-fallback guesses + (RFC 9728/8414-advertised metadata only), and deliberately skips ``registration_url`` + because ``_dcr_bridge_relays_client_registration`` keys off that column. Best-effort: a + failed write re-discovers on the next build. Scopes go through ``update_mcp_server`` so + they merge into the credentials blob without touching the stored client credentials. + """ + if auth_type not in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: + return + if metadata is None or metadata.from_origin_fallback: + return + authorization_url_update = ( + {"authorization_url": metadata.authorization_url} + if metadata.authorization_url and not existing_authorization_url + else {} + ) + token_url_update = {"token_url": metadata.token_url} if metadata.token_url and not existing_token_url else {} + scopes_update = {"credentials": {"scopes": metadata.scopes}} if metadata.scopes and not existing_scopes else {} + updates: dict[str, object] = {**authorization_url_update, **token_url_update, **scopes_update} + if not updates: + return + from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 # db.py imports this module at load + update_mcp_server, + ) + from litellm.proxy._types import UpdateMCPServerRequest # noqa: PLC0415 # heavy module; import at call time + from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # runtime value, set after startup + + if prisma_client is None: + return + try: + await update_mcp_server( + prisma_client=prisma_client, + data=UpdateMCPServerRequest.model_validate({"server_id": server_id, **updates}), + touched_by="mcp_oauth_discovery", + ) + verbose_logger.info( + "Persisted discovered OAuth endpoints for MCP server %s: %s", + server_id, + sorted(updates), + ) + except Exception as exc: # noqa: BLE001 - best-effort; a failed write re-discovers next build + verbose_logger.warning( + "Failed to persist discovered OAuth endpoints for MCP server %s: %s", + server_id, + exc, + ) + async def _maybe_register_openapi_tools(self, server: MCPServer, *, initialize_mapping: bool = True): """Register OpenAPI tools if the server has a spec_path configured.""" if server.spec_path: @@ -1226,6 +1714,10 @@ class MCPServerManager: existing_prefix = self.registry[mcp_server.server_id].short_prefix if existing_prefix and not new_server.short_prefix: new_server.short_prefix = existing_prefix + _carry_forward_resolved_oauth_endpoints( + new_server=new_server, + previous_server=self.registry[mcp_server.server_id], + ) self._assign_unique_short_prefix(new_server) self.registry[mcp_server.server_id] = new_server await self._maybe_register_openapi_tools(new_server) @@ -1235,14 +1727,14 @@ class MCPServerManager: verbose_logger.debug(f"Failed to udpate MCP server: {str(e)}") raise e - def get_all_mcp_server_ids(self) -> Set[str]: + def get_all_mcp_server_ids(self) -> set[str]: """ Get all MCP server IDs """ all_servers = list(self.get_registry().values()) return {server.server_id for server in all_servers} - def get_allow_all_keys_server_ids(self) -> List[str]: + def get_allow_all_keys_server_ids(self) -> list[str]: """Return server IDs that bypass per-key restrictions.""" return [server.server_id for server in self.get_registry().values() if server.allow_all_keys is True] @@ -1307,7 +1799,7 @@ class MCPServerManager: return [server_id for server_id in submitted_server_ids if self.get_mcp_server_by_id(server_id) is not None] - async def get_allowed_mcp_servers(self, user_api_key_auth: Optional[UserAPIKeyAuth] = None) -> List[str]: + async def get_allowed_mcp_servers(self, user_api_key_auth: Optional[UserAPIKeyAuth] = None) -> list[str]: """ Get the allowed MCP Servers for the user. @@ -1383,12 +1875,18 @@ class MCPServerManager: delegate_server_ids = [ server.server_id for server in self.get_registry().values() - if getattr(server, "auth_type", None) == MCPAuth.oauth2 - and getattr(server, "delegate_auth_to_upstream", False) is True - # M2M servers must not be exposed anonymously: an - # unauthenticated caller would get LiteLLM to proxy tool - # calls using its stored client_credentials. - and not server.has_client_credentials + if ( + getattr(server, "auth_type", None) == MCPAuth.oauth2 + and getattr(server, "delegate_auth_to_upstream", False) is True + # M2M servers must not be exposed anonymously: an + # unauthenticated caller would get LiteLLM to proxy tool + # calls using its stored client_credentials. Resolve the flow + # rather than reading has_client_credentials so an unstamped + # M2M-shape row (null column, verbatim-read as non-M2M) still + # fails closed here, matching the anonymous-delegate auth gate. + and MCPServerManager.effective_oauth2_flow(server) != "client_credentials" + ) + or getattr(server, "auth_type", None) == MCPAuth.true_passthrough ] combined_servers.update(delegate_server_ids) @@ -1404,8 +1902,8 @@ class MCPServerManager: async def resolve_toolset_tool_permissions( self, - toolset_ids: List[str], - ) -> Dict[str, List[str]]: + toolset_ids: list[str], + ) -> dict[str, list[str]]: """ Resolve a list of toolset IDs into a mcp_tool_permissions dict. @@ -1427,7 +1925,7 @@ class MCPServerManager: try: toolsets = await list_mcp_toolsets(prisma_client, toolset_ids=toolset_ids) - tool_permissions: Dict[str, List[str]] = {} + tool_permissions: dict[str, list[str]] = {} for toolset in toolsets: for tool in toolset.tools: raw_name = tool["tool_name"] @@ -1520,7 +2018,7 @@ class MCPServerManager: ) return toolset - def filter_server_ids_by_ip(self, server_ids: List[str], client_ip: Optional[str]) -> List[str]: + def filter_server_ids_by_ip(self, server_ids: list[str], client_ip: Optional[str]) -> list[str]: """ Filter server IDs by client IP — external callers only see public servers. @@ -1530,8 +2028,8 @@ class MCPServerManager: return filtered def filter_server_ids_by_ip_with_info( - self, server_ids: List[str], client_ip: Optional[str] - ) -> Tuple[List[str], int]: + self, server_ids: list[str], client_ip: Optional[str] + ) -> tuple[list[str], int]: """ Filter server IDs by client IP — external callers only see public servers. @@ -1551,7 +2049,7 @@ class MCPServerManager: blocked += 1 return allowed, blocked - async def get_tools_for_server(self, server_id: str) -> List[MCPTool]: + async def get_tools_for_server(self, server_id: str) -> list[MCPTool]: """ Get the tools for a given server """ @@ -1569,8 +2067,8 @@ class MCPServerManager: self, user_api_key_auth: Optional[UserAPIKeyAuth] = None, mcp_auth_header: Optional[str] = None, - mcp_server_auth_headers: Optional[Dict[str, Union[str, Dict[str, str]]]] = None, - ) -> List[MCPTool]: + mcp_server_auth_headers: Optional[dict[str, Union[str, dict[str, str]]]] = None, + ) -> list[MCPTool]: """ List all tools available across all MCP Servers. @@ -1587,7 +2085,7 @@ class MCPServerManager: verbose_logger.debug("SERVER MANAGER LISTING TOOLS") - async def _fetch_server_tools(server_id: str) -> List[MCPTool]: + async def _fetch_server_tools(server_id: str) -> list[MCPTool]: """Fetch tools from a single server with error handling.""" server = self.get_mcp_server_by_id(server_id) if server is None: @@ -1595,7 +2093,7 @@ class MCPServerManager: return [] # Get server-specific auth header if available - server_auth_header: Optional[Union[str, Dict[str, str]]] = None + server_auth_header: Optional[Union[str, dict[str, str]]] = None if mcp_server_auth_headers: from litellm.proxy._experimental.mcp_server.utils import ( lookup_mcp_server_auth_in_headers, @@ -1629,7 +2127,7 @@ class MCPServerManager: results = await asyncio.gather(*tasks) # Flatten results into single list - list_tools_result: List[MCPTool] = [tool for tools in results for tool in tools] + list_tools_result: list[MCPTool] = [tool for tools in results for tool in tools] verbose_logger.info(f"Successfully fetched {len(list_tools_result)} tools total from all servers") return list_tools_result @@ -1639,8 +2137,8 @@ class MCPServerManager: ######################################################### @staticmethod def _extract_bearer_token( - oauth2_headers: Optional[Dict[str, str]], - raw_headers: Optional[Dict[str, str]], + oauth2_headers: Optional[dict[str, str]], + raw_headers: Optional[dict[str, str]], ) -> Optional[str]: """Extract the bare Bearer token from oauth2_headers or raw_headers. @@ -1660,17 +2158,32 @@ class MCPServerManager: return auth_value return None + def _obo_subject_token( + self, + server: MCPServer, + raw_headers: Optional[dict[str, str]], + ) -> Optional[str]: + """The caller's bearer as the token_exchange (OBO) subject token, for that mode only. + + Prompts/resources discovery and reads on a token_exchange server must exchange the caller's + token like the tools paths do, not connect with no credential. Other modes never read the + inbound bearer, so return None to avoid forwarding it. + """ + if server.auth_type != MCPAuth.oauth2_token_exchange: + return None + return self._extract_bearer_token(None, raw_headers) + def _build_stdio_env( self, server: MCPServer, - raw_headers: Optional[Dict[str, str]] = None, - ) -> Optional[Dict[str, str]]: + raw_headers: Optional[dict[str, str]] = None, + ) -> Optional[dict[str, str]]: """Resolve stdio env values, supporting header-driven placeholders.""" if server.transport != MCPTransport.stdio or not server.env: return None - resolved_env: Dict[str, str] = {} + resolved_env: dict[str, str] = {} normalized_headers = {k.lower(): v for k, v in (raw_headers or {}).items()} for env_key, env_value in server.env.items(): @@ -1712,7 +2225,7 @@ class MCPServerManager: user_api_key_auth: Optional[UserAPIKeyAuth], *, raise_on_missing: bool = True, - ) -> Optional[Dict[str, str]]: + ) -> Optional[dict[str, str]]: """Return server.static_headers with ``${NAME}`` interpolated. Globals come from ``server.env_vars`` entries with ``scope=="global"``. @@ -1755,7 +2268,7 @@ class MCPServerManager: referenced_user_vars = referenced & user_var_names required_user_vars = {name for name in referenced_user_vars if name not in global_values} - user_values: Dict[str, str] = {} + user_values: dict[str, str] = {} if required_user_vars: try: user_values = await self._load_user_env_vars(server, user_api_key_auth) @@ -1794,7 +2307,7 @@ class MCPServerManager: # admin globals win, so a stale row from when a var was user-scoped can # never override the global value the admin set after switching it. scoped_user_values = {name: value for name, value in user_values.items() if name in user_var_names} - merged_vars: Dict[str, str] = {**scoped_user_values, **global_values} + merged_vars: dict[str, str] = {**scoped_user_values, **global_values} if not static_headers: return static_headers return interpolate_headers(static_headers, merged_vars) @@ -1805,7 +2318,7 @@ class MCPServerManager: user_api_key_auth: Optional[UserAPIKeyAuth], *, force_refresh: bool = False, - ) -> Dict[str, str]: + ) -> dict[str, str]: """Look up the calling user's env var values for ``server``. Returns an empty dict when no user is available. Results are cached in a @@ -1850,12 +2363,99 @@ class MCPServerManager: _write_user_env_vars_cache(user_id, server.server_id, values) return values + async def _resolve_v2_auth( + self, + *, + server: MCPServer, + spec: ServerSpec, + provider: UpstreamCredentialProvider, + subject_token: Optional[str], + user_api_key_auth: Optional[UserAPIKeyAuth], + extra_headers: Optional[dict[str, str]], + ) -> tuple[Optional[httpx.Auth], Optional[dict[str, str]]]: + """Resolve a v2-owned server's upstream credential into ``(resolved_auth, extra_headers)``. + + On a missing/rejected per-user credential this raises the mode's discovery challenge + (authorization_code's browser-OAuth 401, token_exchange's RFC 9728 challenge) or maps any + other ``CredError`` onto its public HTTP status; it never returns an error as a value. + """ + match await provider.resolve_credentials(to_subject(user_api_key_auth, subject_token), spec): + case Ok(auth): + # NoOpAuth has no header_name and so never conflicts. + header_name = getattr(auth, "header_name", None) + conflicts = bool( + header_name and extra_headers and any(key.lower() == header_name.lower() for key in extra_headers) + ) + if not conflicts: + return auth, extra_headers + if isinstance(spec.config, (TokenExchangeConfig, AuthorizationCodeConfig)): + # The resolver owns the per-user credential here (token_exchange's exchanged + # token, authorization_code's stored token). It is authoritative: a guardrail such + # as MCPJWTSigner, static_headers, or any other injected Authorization must NOT + # shadow it (otherwise the upstream gets e.g. the signer's JWT instead of the + # exchanged token and rejects it). Drop the conflicting header so the resolved + # token reaches upstream. + return auth, _without_authorization(extra_headers) + # Other modes: an Authorization already supplied via extra_headers (a forwarded caller + # header or static_headers) is intentional and wins; v1 applies those last. + return None, extra_headers + case Error(err): + if err.tag == "unauthorized" and isinstance(spec.config, AuthorizationCodeConfig): + # authorization_code's missing per-user token -> the per-server browser-OAuth + # challenge, built here where the full MCPServer is in hand. + raise_user_oauth_challenge(server, root_path=get_server_root_path()) + if err.tag == "unauthorized" and isinstance(spec.config, TokenExchangeConfig): + # token_exchange (OBO): a missing/rejected subject token -> the RFC 9728 challenge + # pointing at the IdP the client must SSO with to obtain one, rather than an opaque + # 401. No gateway-side browser flow. An IdP step-up rejection (Entra Conditional + # Access) threads its claims blob into the challenge for the client to satisfy. + raise_token_exchange_challenge( + server, + root_path=get_server_root_path(), + claims=err.unauthorized.claims, + ) + raise_public(err) + + async def preflight_token_exchange( + self, + server: MCPServer, + oauth2_headers: Optional[dict[str, str]], + user_api_key_auth: Optional[UserAPIKeyAuth], + ) -> None: + """Run the OBO exchange for a caller-supplied subject at the transport edge. + + Single-server routes call this before the MCP session opens, where an HTTP status and + ``WWW-Authenticate`` still reach the client. A rejected subject raises the RFC 9728 + challenge and any other ``CredError`` maps onto its public HTTP status, so an exchange + failure surfaces as a failure instead of the session continuing into an empty tool list. + A successful exchange is cached by the exchanger, so the session's list/call reuses it. + """ + if server.auth_type != MCPAuth.oauth2_token_exchange: + return + subject_token = self._extract_bearer_token(oauth2_headers, None) + if not subject_token: + return + spec = to_server_spec(server) + if spec is None or not isinstance(spec.config, TokenExchangeConfig): + return + match await self._cred_provider.resolve_credentials(to_subject(user_api_key_auth, subject_token), spec): + case Ok(_): + return + case Error(err): + if err.tag == "unauthorized": + raise_token_exchange_challenge( + server, + root_path=get_server_root_path(), + claims=err.unauthorized.claims, + ) + raise_public(err) + async def _create_mcp_client( self, server: MCPServer, - mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, - extra_headers: Optional[Dict[str, str]] = None, - stdio_env: Optional[Dict[str, str]] = None, + mcp_auth_header: Optional[Union[str, dict[str, str]]] = None, + extra_headers: Optional[dict[str, str]] = None, + stdio_env: Optional[dict[str, str]] = None, subject_token: Optional[str] = None, user_api_key_auth: Optional[UserAPIKeyAuth] = None, cred_provider: Optional[UpstreamCredentialProvider] = None, @@ -1884,11 +2484,18 @@ class MCPServerManager: spec = None if transport == MCPTransport.stdio else to_server_spec(server) provider = cred_provider or self._cred_provider # A caller-supplied per-request override (mcp_auth_header / x-mcp-*) defers to the v1 path - # so it wins - except for authorization_code, whose per-user token the v2 resolver owns. A - # caller must not be able to substitute another user's stored credential, so we keep the v2 - # spec and ignore the override there; the REST tools preview supplies its not-yet-persisted - # token through the resolver (cred_provider), never this path. - if spec is not None and mcp_auth_header and not isinstance(spec.config, AuthorizationCodeConfig): + # so it wins - except for the modes the v2 resolver owns per-caller (authorization_code's + # stored token, token_exchange's RFC 8693 minted token, and the passthrough modes' + # forwarded caller token). A caller must not be able to substitute another user's stored + # credential, nor silently disable the OBO exchange and forward an arbitrary bearer + # upstream, so we keep the v2 spec and ignore the override for these; the REST tools + # preview supplies its not-yet-persisted token through the resolver (cred_provider), + # never this path. + if ( + spec is not None + and mcp_auth_header + and not isinstance(spec.config, (AuthorizationCodeConfig, PassthroughConfig, TokenExchangeConfig)) + ): spec = None auth_value = ( await resolve_mcp_auth(server, mcp_auth_header, subject_token=subject_token) if spec is None else None @@ -1954,26 +2561,20 @@ class MCPServerManager: server_url = server.url or "" if spec is not None: - match await provider.resolve_credentials(to_subject(user_api_key_auth, subject_token), spec): - case Ok(auth): - resolved_auth = auth - # Do not override an Authorization already supplied via extra_headers - # (a guardrail hook such as the JWT signer, static_headers, or a - # forwarded caller header): v1 applies those last, so they win. NoOpAuth - # has no header_name and so never skips. - header_name = getattr(resolved_auth, "header_name", None) - if ( - header_name - and extra_headers - and any(key.lower() == header_name.lower() for key in extra_headers) - ): - resolved_auth = None - case Error(err): - if err.tag == "unauthorized": - # The arm signals a missing per-user token semantically; raise the - # per-server OAuth challenge here, where the full MCPServer is in hand. - raise_user_oauth_challenge(server) - raise_public(err) + inbound_token = subject_token + if isinstance(spec.config, PassthroughConfig): + inbound_token, extra_headers = _take_forwarded_authorization(extra_headers) + per_server_token = _passthrough_token_from_mcp_auth_header(mcp_auth_header) + if per_server_token is not None: + inbound_token = per_server_token + resolved_auth, extra_headers = await self._resolve_v2_auth( + server=server, + spec=spec, + provider=provider, + subject_token=inbound_token, + user_api_key_auth=user_api_key_auth, + extra_headers=extra_headers, + ) return MCPClient( server_url=server_url, transport_type=transport, @@ -2013,12 +2614,13 @@ class MCPServerManager: async def _get_tools_from_server( self, server: MCPServer, - mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, - extra_headers: Optional[Dict[str, str]] = None, + mcp_auth_header: Optional[Union[str, dict[str, str]]] = None, + extra_headers: Optional[dict[str, str]] = None, add_prefix: bool = True, - raw_headers: Optional[Dict[str, str]] = None, + raw_headers: Optional[dict[str, str]] = None, user_api_key_auth: Optional[UserAPIKeyAuth] = None, - ) -> List[MCPTool]: + oauth2_headers: Optional[dict[str, str]] = None, + ) -> list[MCPTool]: """ Helper method to get tools from a single MCP server with prefixed names. @@ -2091,11 +2693,21 @@ class MCPServerManager: stdio_env = self._build_stdio_env(server, raw_headers) + # token_exchange (OBO) discovery needs the caller's token too: list it with the user's own + # token (mirrors the call path), not v1's deleted client_credentials fallback. Other modes + # never read the inbound bearer, so leave subject_token None to avoid forwarding it. + subject_token = ( + self._extract_bearer_token(oauth2_headers, raw_headers) + if server.auth_type == MCPAuth.oauth2_token_exchange + else None + ) + client = await self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, stdio_env=stdio_env, + subject_token=subject_token, user_api_key_auth=user_api_key_auth, ) @@ -2122,18 +2734,40 @@ class MCPServerManager: ] return tools else: - tools = await self._fetch_tools_with_timeout(client, server.name, server=server) + tools = await self._fetch_tools_with_timeout(client, server.name) self._remember_upstream_initialize_instructions(server, client) prefixed_or_original_tools = self._create_prefixed_tools(tools, server, add_prefix=add_prefix) return prefixed_or_original_tools - except MCPUpstreamAuthError: + except MCPUpstreamAuthError as upstream_auth_error: # Pass-through 401 must surface to single-server routes so the # client triggers the upstream OAuth flow. The multi-server # aggregator catches this explicitly to keep absorbing. + if server.is_dcr_bridge and upstream_auth_error.www_authenticate is not None: + raise MCPUpstreamAuthError( + status_code=upstream_auth_error.status_code, + www_authenticate=None, + server_name=upstream_auth_error.server_name, + ) from upstream_auth_error raise + except HTTPException as e: + # A v2 resolver auth challenge (token_exchange's RFC 9728 401, authorization_code's + # browser-OAuth 401, or a 403) is raised at client-build time, inside this try. Route it + # through the same MCPUpstreamAuthError channel as pass-through so single-server routes + # surface the challenge (the client re-authenticates) while the aggregator keeps absorbing. + # Non-auth HTTP errors stay absorbed so one misconfigured server can't blank the listing. + if e.status_code in (401, 403): + headers = e.headers or {} + challenge_header = headers.get("WWW-Authenticate") or headers.get("www-authenticate") + raise MCPUpstreamAuthError( + status_code=e.status_code, + www_authenticate=None if server.is_dcr_bridge else challenge_header, + server_name=server.name, + ) from e + verbose_logger.warning(f"Failed to get tools from server {server.name}: {str(e)}") + return [] except Exception as e: verbose_logger.warning(f"Failed to get tools from server {server.name}: {str(e)}") return [] @@ -2141,11 +2775,11 @@ class MCPServerManager: async def get_prompts_from_server( self, server: MCPServer, - mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, - extra_headers: Optional[Dict[str, str]] = None, + mcp_auth_header: Optional[Union[str, dict[str, str]]] = None, + extra_headers: Optional[dict[str, str]] = None, add_prefix: bool = True, - raw_headers: Optional[Dict[str, str]] = None, - ) -> List[Prompt]: + raw_headers: Optional[dict[str, str]] = None, + ) -> list[Prompt]: """ Helper method to get prompts from a single MCP server with prefixed names. @@ -2169,12 +2803,14 @@ class MCPServerManager: extra_headers.update(server.static_headers) stdio_env = self._build_stdio_env(server, raw_headers) + subject_token = self._obo_subject_token(server, raw_headers) client = await self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, stdio_env=stdio_env, + subject_token=subject_token, ) prompts = await client.list_prompts() @@ -2190,11 +2826,11 @@ class MCPServerManager: async def get_resources_from_server( self, server: MCPServer, - mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, - extra_headers: Optional[Dict[str, str]] = None, + mcp_auth_header: Optional[Union[str, dict[str, str]]] = None, + extra_headers: Optional[dict[str, str]] = None, add_prefix: bool = True, - raw_headers: Optional[Dict[str, str]] = None, - ) -> List[Resource]: + raw_headers: Optional[dict[str, str]] = None, + ) -> list[Resource]: """Fetch available resources from a single MCP server.""" verbose_logger.debug(f"Connecting to url: {server.url}") @@ -2209,12 +2845,14 @@ class MCPServerManager: extra_headers.update(server.static_headers) stdio_env = self._build_stdio_env(server, raw_headers) + subject_token = self._obo_subject_token(server, raw_headers) client = await self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, stdio_env=stdio_env, + subject_token=subject_token, ) resources = await client.list_resources() @@ -2230,11 +2868,11 @@ class MCPServerManager: async def get_resource_templates_from_server( self, server: MCPServer, - mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, - extra_headers: Optional[Dict[str, str]] = None, + mcp_auth_header: Optional[Union[str, dict[str, str]]] = None, + extra_headers: Optional[dict[str, str]] = None, add_prefix: bool = True, - raw_headers: Optional[Dict[str, str]] = None, - ) -> List[ResourceTemplate]: + raw_headers: Optional[dict[str, str]] = None, + ) -> list[ResourceTemplate]: """Fetch available resource templates from a single MCP server.""" verbose_logger.debug(f"Connecting to url: {server.url}") @@ -2249,12 +2887,14 @@ class MCPServerManager: extra_headers.update(server.static_headers) stdio_env = self._build_stdio_env(server, raw_headers) + subject_token = self._obo_subject_token(server, raw_headers) client = await self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, stdio_env=stdio_env, + subject_token=subject_token, ) resource_templates = await client.list_resource_templates() @@ -2273,9 +2913,9 @@ class MCPServerManager: self, server: MCPServer, url: AnyUrl, - mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, - extra_headers: Optional[Dict[str, str]] = None, - raw_headers: Optional[Dict[str, str]] = None, + mcp_auth_header: Optional[Union[str, dict[str, str]]] = None, + extra_headers: Optional[dict[str, str]] = None, + raw_headers: Optional[dict[str, str]] = None, ) -> ReadResourceResult: """Read resource contents from a specific MCP server.""" @@ -2288,12 +2928,14 @@ class MCPServerManager: extra_headers.update(server.static_headers) stdio_env = self._build_stdio_env(server, raw_headers) + subject_token = self._obo_subject_token(server, raw_headers) client = await self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, stdio_env=stdio_env, + subject_token=subject_token, ) return await client.read_resource(url) @@ -2302,10 +2944,10 @@ class MCPServerManager: self, server: MCPServer, prompt_name: str, - arguments: Optional[Dict[str, Any]] = None, - mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, - extra_headers: Optional[Dict[str, str]] = None, - raw_headers: Optional[Dict[str, str]] = None, + arguments: Optional[dict[str, Any]] = None, + mcp_auth_header: Optional[Union[str, dict[str, str]]] = None, + extra_headers: Optional[dict[str, str]] = None, + raw_headers: Optional[dict[str, str]] = None, ) -> GetPromptResult: """Fetch a specific prompt definition from a single MCP server.""" @@ -2318,12 +2960,14 @@ class MCPServerManager: extra_headers.update(server.static_headers) stdio_env = self._build_stdio_env(server, raw_headers) + subject_token = self._obo_subject_token(server, raw_headers) client = await self._create_mcp_client( server=server, mcp_auth_header=mcp_auth_header, extra_headers=extra_headers, stdio_env=stdio_env, + subject_token=subject_token, ) get_prompt_request_params = GetPromptRequestParams( @@ -2376,8 +3020,17 @@ class MCPServerManager: async def _descovery_metadata( self, server_url: str, + *, + allow_origin_fallback: bool = True, ) -> Optional[MCPOAuthMetadata]: - """Discover OAuth metadata by following RFC 9728 (protected resource metadata discovery).""" + """Discover OAuth metadata by following RFC 9728 (protected resource metadata discovery). + + ``allow_origin_fallback`` controls the last-resort guess that treats the resource server's own + origin as its authorization server when nothing is advertised. The browser ``oauth2`` flow keeps + it (a human sees the redirect), but token_exchange (OBO) sets it False so the gateway never + exchanges a subject token against an endpoint it inferred rather than one explicitly configured + or authoritatively advertised via RFC 9728 / RFC 8414. + """ try: client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) @@ -2427,16 +3080,20 @@ class MCPServerManager: ) = await self._attempt_well_known_discovery(server_url) metadata = None - if not authorization_servers: + used_origin_fallback = False + if allow_origin_fallback and not authorization_servers: try: parsed_url = urlparse(server_url) if parsed_url.scheme and parsed_url.netloc: authorization_servers = [f"{parsed_url.scheme}://{parsed_url.netloc}"] + used_origin_fallback = True except Exception: authorization_servers = [] if authorization_servers: metadata = await self._fetch_authorization_server_metadata(authorization_servers, server_url) + if metadata is not None and used_origin_fallback: + metadata.from_origin_fallback = True preferred_scopes = scopes or resource_scopes if metadata is None and preferred_scopes: @@ -2449,7 +3106,7 @@ class MCPServerManager: verbose_logger.debug("MCP OAuth discovery failed for %s: %s", server_url, exc) return None - def _parse_www_authenticate_header(self, header_value: Optional[str]) -> Tuple[Optional[str], Optional[List[str]]]: + def _parse_www_authenticate_header(self, header_value: Optional[str]) -> tuple[Optional[str], Optional[list[str]]]: if not header_value: return None, None @@ -2457,7 +3114,7 @@ class MCPServerManager: params_section = params_section or header_value param_pattern = re.compile(r"([a-zA-Z0-9_]+)\s*=\s*\"?([^\",]+)\"?") - params: Dict[str, str] = { + params: dict[str, str] = { match.group(1).lower(): match.group(2).strip() for match in param_pattern.finditer(params_section) } @@ -2471,7 +3128,7 @@ class MCPServerManager: async def _fetch_oauth_metadata_from_resource( self, resource_metadata_url: str, server_url: str - ) -> Tuple[List[str], Optional[List[str]]]: + ) -> tuple[list[str], Optional[list[str]]]: if not resource_metadata_url: return [], None @@ -2506,7 +3163,7 @@ class MCPServerManager: return authorization_servers, scopes - async def _attempt_well_known_discovery(self, server_url: str) -> Tuple[List[str], Optional[List[str]]]: + async def _attempt_well_known_discovery(self, server_url: str) -> tuple[list[str], Optional[list[str]]]: try: parsed = urlparse(server_url) except Exception: @@ -2519,7 +3176,7 @@ class MCPServerManager: path = parsed.path or "" path = path.strip("/") - candidate_urls: List[str] = [] + candidate_urls: list[str] = [] if path: candidate_urls.append(f"{base}/.well-known/oauth-protected-resource/{path}") candidate_urls.append(f"{base}/.well-known/oauth-protected-resource") @@ -2535,7 +3192,7 @@ class MCPServerManager: return [], None async def _fetch_authorization_server_metadata( - self, authorization_servers: List[str], server_url: str + self, authorization_servers: list[str], server_url: str ) -> Optional[MCPOAuthMetadata]: for issuer in authorization_servers: metadata = await self._fetch_single_authorization_server_metadata(issuer, server_url) @@ -2557,7 +3214,7 @@ class MCPServerManager: base = f"{parsed.scheme}://{parsed.netloc}" path = (parsed.path or "").strip("/") - candidate_urls: List[str] = [] + candidate_urls: list[str] = [] if path: candidate_urls.append(f"{base}/.well-known/oauth-authorization-server/{path}") candidate_urls.append(f"{base}/.well-known/openid-configuration/{path}") @@ -2589,6 +3246,14 @@ class MCPServerManager: continue scopes = self._extract_scopes(data.get("scopes_supported")) + verbose_logger.debug( + "Authorization server metadata from %s: issuer=%s grant_types_supported=%s " + "token_endpoint_auth_methods_supported=%s", + url, + data.get("issuer"), + data.get("grant_types_supported"), + data.get("token_endpoint_auth_methods_supported"), + ) metadata = MCPOAuthMetadata( scopes=scopes, authorization_url=data.get("authorization_endpoint"), @@ -2643,9 +3308,9 @@ class MCPServerManager: def _extract_aws_credentials( self, - credentials_dict: Optional[Dict[str, str]], + credentials_dict: Optional[dict[str, str]], credentials_are_encrypted: bool, - ) -> Dict[str, Optional[str]]: + ) -> dict[str, Optional[str]]: """Extract and decrypt AWS SigV4 credential fields from credentials dict.""" if not credentials_dict: return {} @@ -2671,7 +3336,7 @@ class MCPServerManager: "aws_session_name": credentials_dict.get("aws_session_name"), } - def _extract_scopes(self, scopes_value: Any) -> Optional[List[str]]: + def _extract_scopes(self, scopes_value: Any) -> Optional[list[str]]: if isinstance(scopes_value, str): scopes = [s.strip() for s in scopes_value.split() if s.strip()] return scopes or None @@ -2684,46 +3349,34 @@ class MCPServerManager: self, client: MCPClient, server_name: str, - server: Optional[MCPServer] = None, - ) -> List[MCPTool]: + ) -> list[MCPTool]: """ Fetch tools from MCP client with timeout and error handling. Uses anyio.fail_after() instead of asyncio.wait_for() to avoid conflicts with the MCP SDK's anyio TaskGroup. See GitHub issue #20715 for details. - For OAuth pass-through and upstream-delegated OAuth2 MCP servers, an - upstream HTTP 401 is converted into :class:`MCPUpstreamAuthError` - instead of being swallowed to an empty tool list. That lets the - single-server HTTP routes surface a proper 401 + ``WWW-Authenticate`` - challenge so standards-compliant MCP clients trigger the upstream - OAuth flow. Other servers keep today's swallow-and-log behaviour so - the multi-server ``/mcp`` aggregator doesn't get tainted by a single - bad server. + An upstream HTTP 401 is converted into :class:`MCPUpstreamAuthError` + instead of being swallowed to an empty tool list, regardless of the + server's auth_type. Callers route it by surface: the single-server HTTP + routes turn it into a 401 + ``WWW-Authenticate`` challenge so standards- + compliant MCP clients trigger the upstream OAuth flow, while the + multi-server ``/mcp`` aggregator absorbs it to an empty list so one + unauthenticated server doesn't fail the whole listing. Only a 401 + (missing/invalid credential) drives the re-auth challenge; a 403 + (authenticated but forbidden, e.g. insufficient scope) is not a re-auth + signal and, like other non-auth errors, returns an empty list. Args: client: MCP client instance server_name: Name of the server for logging - server: Optional MCPServer; when upstream auth is delegated, auth - errors are re-raised as :class:`MCPUpstreamAuthError`. Returns: List of tools from the server """ - should_surface_upstream_auth = bool( - server is not None - and ( - server.is_oauth_passthrough - or ( - server.auth_type == MCPAuth.oauth2 - and getattr(server, "delegate_auth_to_upstream", False) is True - and not server.has_client_credentials - ) - ) - ) try: with anyio.fail_after(MCP_TOOL_LISTING_TIMEOUT): - tools = await client.list_tools(raise_on_error=should_surface_upstream_auth) + tools = await client.list_tools(raise_on_error=True) verbose_logger.debug(f"Tools from {server_name}: {tools}") return tools except TimeoutError: @@ -2736,16 +3389,15 @@ class MCPServerManager: verbose_logger.warning(f"Connection error while listing tools from {server_name}: {str(e)}") return [] except Exception as e: - if should_surface_upstream_auth: - auth_info = _extract_upstream_auth_failure(e) - if auth_info is not None: - status_code, www_authenticate = auth_info - verbose_logger.info(f"Upstream auth failure from MCP server {server_name}: HTTP {status_code}") - raise MCPUpstreamAuthError( - status_code=status_code, - www_authenticate=www_authenticate, - server_name=server_name, - ) from e + auth_info = _extract_upstream_auth_failure(e) + if auth_info is not None and auth_info[0] == 401: + _, www_authenticate = auth_info + verbose_logger.info(f"Upstream auth failure from MCP server {server_name}: HTTP 401") + raise MCPUpstreamAuthError( + status_code=401, + www_authenticate=www_authenticate, + server_name=server_name, + ) from e verbose_logger.warning(f"Error listing tools from {server_name}: {str(e)}") return [] @@ -2754,7 +3406,7 @@ class MCPServerManager: def _assign_unique_short_prefix( self, server: MCPServer, - registry: Optional[Dict[str, MCPServer]] = None, + registry: Optional[dict[str, MCPServer]] = None, ) -> None: """Resolve and cache a collision-free short tool prefix on ``server``. @@ -2778,7 +3430,7 @@ class MCPServerManager: if not server.server_id: return - used: Dict[str, str] = {} + used: dict[str, str] = {} registry_for_collision_check = registry or self.get_registry() for other in registry_for_collision_check.values(): if other.server_id == server.server_id: @@ -2811,7 +3463,7 @@ class MCPServerManager: "attempts; the 3-character prefix space is too crowded." ) - def _create_prefixed_tools(self, tools: List[MCPTool], server: MCPServer, add_prefix: bool = True) -> List[MCPTool]: + def _create_prefixed_tools(self, tools: list[MCPTool], server: MCPServer, add_prefix: bool = True) -> list[MCPTool]: """ Create prefixed tools and update tool mapping. @@ -2849,8 +3501,8 @@ class MCPServerManager: return prefixed_tools def _create_prefixed_prompts( - self, prompts: List[Prompt], server: MCPServer, add_prefix: bool = True - ) -> List[Prompt]: + self, prompts: list[Prompt], server: MCPServer, add_prefix: bool = True + ) -> list[Prompt]: """ Create prefixed prompts and update prompt mapping. @@ -2876,11 +3528,11 @@ class MCPServerManager: return prefixed_prompts def _create_prefixed_resources( - self, resources: List[Resource], server: MCPServer, add_prefix: bool = True - ) -> List[Resource]: + self, resources: list[Resource], server: MCPServer, add_prefix: bool = True + ) -> list[Resource]: """Prefix resource names and track origin server for read requests.""" - prefixed_resources: List[Resource] = [] + prefixed_resources: list[Resource] = [] prefix = get_server_prefix(server) for resource in resources: @@ -2893,13 +3545,13 @@ class MCPServerManager: def _create_prefixed_resource_templates( self, - resource_templates: List[ResourceTemplate], + resource_templates: list[ResourceTemplate], server: MCPServer, add_prefix: bool = True, - ) -> List[ResourceTemplate]: + ) -> list[ResourceTemplate]: """Prefix resource template names for multi-server scenarios.""" - prefixed_templates: List[ResourceTemplate] = [] + prefixed_templates: list[ResourceTemplate] = [] prefix = get_server_prefix(server) for resource_template in resource_templates: @@ -2932,7 +3584,7 @@ class MCPServerManager: ) return True - def validate_allowed_params(self, tool_name: str, arguments: Dict[str, Any], server: MCPServer) -> None: + def validate_allowed_params(self, tool_name: str, arguments: dict[str, Any], server: MCPServer) -> None: """ Filter arguments to only include allowed parameters for the given tool. @@ -3023,7 +3675,7 @@ class MCPServerManager: self, server: MCPServer, tool_name: str, - arguments: Dict[str, Any], + arguments: dict[str, Any], ) -> CallToolResult: """ Call an OpenAPI tool handler directly. @@ -3080,13 +3732,13 @@ class MCPServerManager: async def pre_call_tool_check( self, name: str, - arguments: Dict[str, Any], + arguments: dict[str, Any], server_name: str, user_api_key_auth: Optional[UserAPIKeyAuth], proxy_logging_obj: ProxyLogging, server: MCPServer, - raw_headers: Optional[Dict[str, str]] = None, - ) -> Dict[str, Any]: + raw_headers: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: """ Run pre-call checks and guardrail hooks for an MCP tool call. @@ -3146,7 +3798,7 @@ class MCPServerManager: # Convert to LLM format for existing guardrail compatibility synthetic_llm_data = proxy_logging_obj._convert_mcp_to_llm_format(mcp_request_obj, pre_hook_kwargs) - hook_result: Dict[str, Any] = {} + hook_result: dict[str, Any] = {} try: # Use standard pre_call_hook modified_data = await proxy_logging_obj.pre_call_hook( @@ -3176,7 +3828,7 @@ class MCPServerManager: def _create_during_hook_task( self, name: str, - arguments: Dict[str, Any], + arguments: dict[str, Any], server_name_from_prefix: Optional[str], user_api_key_auth: Optional[UserAPIKeyAuth], proxy_logging_obj: ProxyLogging, @@ -3211,19 +3863,79 @@ class MCPServerManager: ) ) + def _get_call_semaphore(self, mcp_server: MCPServer) -> Optional[asyncio.Semaphore]: + limit = mcp_server.max_concurrent_requests + if limit is None or limit <= 0: + return None + cached = self._server_call_semaphores.get(mcp_server.server_id) + if cached is not None and cached[0] == limit: + return cached[1] + semaphore = asyncio.Semaphore(limit) + self._server_call_semaphores[mcp_server.server_id] = (limit, semaphore) + return semaphore + + @asynccontextmanager + async def _limit_outbound_concurrency(self, mcp_server: MCPServer) -> AsyncIterator[None]: + semaphore = self._get_call_semaphore(mcp_server) + if semaphore is None: + yield + return + async with semaphore: + yield + + async def _obo_call_tool_with_retry( + self, + *, + client: MCPClient, + call_tool_params: MCPCallToolRequestParams, + host_progress_callback: Optional[Callable], + mcp_server: MCPServer, + server_auth_header: str | dict[str, str] | None, + extra_headers: Optional[dict[str, str]], + stdio_env: Optional[dict[str, str]], + subject_token: Optional[str], + user_api_key_auth: Optional[UserAPIKeyAuth], + ) -> CallToolResult: + """Call a token_exchange (OBO) tool; on an upstream 401/403 re-mint the token once and retry. + + The exchanged token is baked into the client at build time, so the retry invalidates the + cached exchange and rebuilds the client (which re-exchanges). One retry only: a non-auth + failure or a second auth failure degrades to the normal ``isError`` result, and a re-exchange + that now fails surfaces its own 401 challenge from ``_create_mcp_client``. + """ + try: + return await client.call_tool( + call_tool_params, host_progress_callback=host_progress_callback, raise_on_error=True + ) + except Exception as exc: + if _extract_upstream_auth_failure(exc) is None: + return MCPClient.error_tool_result(exc) + spec = to_server_spec(mcp_server) + if spec is not None: + await self._cred_provider.invalidate_credentials(to_subject(user_api_key_auth, subject_token), spec) + retry_client = await self._create_mcp_client( + server=mcp_server, + mcp_auth_header=server_auth_header, + extra_headers=extra_headers, + stdio_env=stdio_env, + subject_token=subject_token, + user_api_key_auth=user_api_key_auth, + ) + return await retry_client.call_tool(call_tool_params, host_progress_callback=host_progress_callback) + async def _call_regular_mcp_tool( self, mcp_server: MCPServer, original_tool_name: str, - arguments: Dict[str, Any], - tasks: List, + arguments: dict[str, Any], + tasks: list, mcp_auth_header: Optional[str], - mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]], - oauth2_headers: Optional[Dict[str, str]], - raw_headers: Optional[Dict[str, str]], + mcp_server_auth_headers: Optional[dict[str, dict[str, str]]], + oauth2_headers: Optional[dict[str, str]], + raw_headers: Optional[dict[str, str]], proxy_logging_obj: Optional[ProxyLogging], host_progress_callback: Optional[Callable] = None, - hook_extra_headers: Optional[Dict[str, str]] = None, + hook_extra_headers: Optional[dict[str, str]] = None, user_api_key_auth: Optional[UserAPIKeyAuth] = None, ) -> CallToolResult: """ @@ -3254,7 +3966,7 @@ class MCPServerManager: # Get server-specific auth header if available (case-insensitive) # FIX: Added case-insensitive matching to handle auth header keys that may not match # the exact case of server alias/name (e.g., '1litellmagcgateway' vs '1LiteLLMAGCGateway') - server_auth_header: Optional[Union[Dict[str, str], str]] = None + server_auth_header: Optional[Union[dict[str, str], str]] = None if mcp_server_auth_headers: # Normalize keys for case-insensitive lookup from litellm.proxy._experimental.mcp_server.utils import ( @@ -3273,7 +3985,7 @@ class MCPServerManager: # Extract subject token for OAuth2 Token Exchange (OBO) flow subject_token: Optional[str] = None - extra_headers: Optional[Dict[str, str]] = None + extra_headers: Optional[dict[str, str]] = None if mcp_server.auth_type == MCPAuth.oauth2_token_exchange: subject_token = self._extract_bearer_token(oauth2_headers, raw_headers) elif mcp_server.auth_type == MCPAuth.oauth2: @@ -3292,6 +4004,13 @@ class MCPServerManager: user_api_key_auth=user_api_key_auth, ): extra_headers = _without_authorization(extra_headers) + elif mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate: + extra_headers = _client_forwarded_authorization_headers( + mcp_server=mcp_server, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) if mcp_server.extra_headers and raw_headers: if extra_headers is None: @@ -3369,10 +4088,74 @@ class MCPServerManager: arguments=arguments, ) - async def _call_tool_via_client(client, params): - return await client.call_tool(params, host_progress_callback=host_progress_callback) + if mcp_server.auth_type == MCPAuth.oauth2_token_exchange and subject_token: + # OBO: the exchanged token may have been revoked/rotated upstream since it was cached, so + # an upstream 401 gets one re-mint + retry. Gated to this mode; all others keep the plain + # single call below. + async def _obo_call_tool_limited(): + async with self._limit_outbound_concurrency(mcp_server): + return await self._obo_call_tool_with_retry( + client=client, + call_tool_params=call_tool_params, + host_progress_callback=host_progress_callback, + mcp_server=mcp_server, + server_auth_header=server_auth_header, + extra_headers=extra_headers, + stdio_env=stdio_env, + subject_token=subject_token, + user_api_key_auth=user_api_key_auth, + ) - tasks.append(asyncio.create_task(_call_tool_via_client(client, call_tool_params))) + tool_call_coro = _obo_call_tool_limited() + else: + # Scoped to the two client-forwarded token modes this stack introduced; legacy + # oauth2 + delegate_auth_to_upstream (is_oauth_passthrough) is being removed, so it is not + # added here even though the list path still relays for it. + relays_upstream_auth = mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate + server_label = mcp_server.name or mcp_server.server_name or mcp_server.alias or "" + + async def _call_tool_via_client(client, params): + async with self._limit_outbound_concurrency(mcp_server): + if not relays_upstream_auth: + return await client.call_tool(params, host_progress_callback=host_progress_callback) + # The client-forwarded modes carry the caller's own upstream token, so an upstream + # 401 (expired/invalid token) is the caller's to resolve: relay it as + # MCPUpstreamAuthError so single-server REST callers turn it into a 401 + + # WWW-Authenticate and re-run the upstream OAuth flow. Only 401 is a re-auth signal + # (mirrors the list path and MCPUpstreamAuthError's contract); a 403 is a genuine + # authorization failure that re-auth won't fix, so it takes the non-auth branch and + # stays a visible warning. raise_on_error only re-raises transport failures + # (tool-level isError results are still returned normally); a non-auth failure keeps + # the same isError degradation the default path produces. + try: + return await client.call_tool( + params, host_progress_callback=host_progress_callback, raise_on_error=True + ) + except Exception as e: + auth_info = _extract_upstream_auth_failure(e) + if auth_info is None or auth_info[0] != 401: + # A genuine (non-auth or 403-forbidden) upstream/transport failure. + # raise_on_error demoted the client-layer log to debug, so surface it here at + # warning level to keep the outage visible; the caller still gets the graceful + # isError result the default masking path would have produced. Log the + # exception type only, never str(e), which for an httpx error embeds the + # upstream URL (a credential can hide in it). + verbose_logger.warning( + "Pass-through MCP tool call failed against %s (non-auth, %s)", + server_label, + type(e).__name__, + ) + return client.error_tool_result(e) + _, www_authenticate = auth_info + raise MCPUpstreamAuthError( + status_code=401, + www_authenticate=www_authenticate, + server_name=server_label, + ) from e + + tool_call_coro = _call_tool_via_client(client, call_tool_params) + + tasks.append(asyncio.create_task(tool_call_coro)) _timeout = mcp_server.timeout if mcp_server.timeout is not None else MCP_CLIENT_TIMEOUT try: @@ -3457,12 +4240,34 @@ class MCPServerManager: return False return await self._cred_provider.has_user_token(to_subject(user_api_key_auth, None), spec) + async def invalidate_user_oauth_token_cache(self, user_id: str, server_id: str) -> None: + """Drop every cached token for ``(user_id, server_id)`` after the credential row changes + (re-auth, revoke, config-change purge): the v2 chain's cache and the legacy per-user token + cache, so the next resolve reads the new row instead of serving the replaced token until its + cache TTL, whichever path resolves it. This is the single invalidation point for per-user + OAuth tokens; callers must not evict individual caches directly. Best-effort: a cache-drop + failure is logged, never raised, because the DB write already succeeded and the TTL remains + the backstop. + """ + try: + await self._per_user_oauth_token_store.invalidate(user_id, server_id) + except Exception as exc: # noqa: BLE001 - cache drop is best-effort; TTL is the backstop + verbose_logger.warning( + "Failed to invalidate cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc + ) + try: + await self._per_user_token_cache.delete(user_id, server_id) + except Exception as exc: # noqa: BLE001 - cache drop is best-effort; TTL is the backstop + verbose_logger.warning( + "Failed to drop legacy cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc + ) + async def _resolve_oauth2_headers_for_tool_call( self, mcp_server: MCPServer, - oauth2_headers: Optional[Dict[str, str]], + oauth2_headers: Optional[dict[str, str]], user_api_key_auth: Optional[UserAPIKeyAuth], - ) -> Optional[Dict[str, str]]: + ) -> Optional[dict[str, str]]: """Look up per-user OAuth headers when the client did not supply a token.""" if not mcp_server.needs_user_oauth_token or oauth2_headers or user_api_key_auth is None: return oauth2_headers @@ -3499,7 +4304,7 @@ class MCPServerManager: async def _gather_openapi_tool_tasks( self, - tasks: List[Any], + tasks: list[Any], proxy_logging_obj: Optional[ProxyLogging], ) -> CallToolResult: """Await OpenAPI tool tasks and return the tool call result.""" @@ -3519,13 +4324,13 @@ class MCPServerManager: self, server_name: str, name: str, - arguments: Dict[str, Any], + arguments: dict[str, Any], user_api_key_auth: Optional[UserAPIKeyAuth] = None, mcp_auth_header: Optional[str] = None, - mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]] = None, + mcp_server_auth_headers: Optional[dict[str, dict[str, str]]] = None, proxy_logging_obj: Optional[ProxyLogging] = None, - oauth2_headers: Optional[Dict[str, str]] = None, - raw_headers: Optional[Dict[str, str]] = None, + oauth2_headers: Optional[dict[str, str]] = None, + raw_headers: Optional[dict[str, str]] = None, host_progress_callback: Optional[Callable] = None, ) -> CallToolResult: """ @@ -3547,12 +4352,21 @@ class MCPServerManager: start_time = datetime.datetime.now() mcp_server = self._resolve_mcp_server_for_tool_call(server_name, name) + # Resolved before any hook runs so a missing BYOK credential (401) never + # leaves during-hook side effects (audit logging, rate-limit bookkeeping) + # recorded against a call that ultimately fails. + mcp_auth_header = await _resolve_byok_mcp_auth_header( + mcp_server, + user_api_key_auth, + mcp_auth_header, + ) + ######################################################### # Pre MCP Tool Call Hook # Allow validation and modification of tool calls before execution # Using standard pre_call_hook ######################################################### - hook_result: Dict[str, Any] = {} + hook_result: dict[str, Any] = {} if proxy_logging_obj: hook_result = await self.pre_call_tool_check( name=name, @@ -3592,7 +4406,28 @@ class MCPServerManager: "transport to enable hook header injection.", server_name, ) - tasks.append(asyncio.create_task(self._call_openapi_tool_handler(mcp_server, name, arguments))) + + auth_header_value = ( + _format_byok_openapi_auth_header(mcp_server, mcp_auth_header) if mcp_auth_header else None + ) + forwarded_headers = _openapi_forwarded_extra_headers(mcp_server, raw_headers, user_api_key_auth) + + async def _call_openapi_via_handler(): + from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + _request_auth_header, + _request_extra_headers, + ) + + auth_token = _request_auth_header.set(auth_header_value) + extra_token = _request_extra_headers.set(forwarded_headers) + try: + async with self._limit_outbound_concurrency(mcp_server): + return await self._call_openapi_tool_handler(mcp_server, name, arguments) + finally: + _request_auth_header.reset(auth_token) + _request_extra_headers.reset(extra_token) + + tasks.append(asyncio.create_task(_call_openapi_via_handler())) else: return await self._call_regular_mcp_tool( mcp_server=mcp_server, @@ -3673,7 +4508,7 @@ class MCPServerManager: # Build prefix → server lookup covering every known form a tool name # may take (alias / server_name / server_id / short ID). This is what # makes the short-prefix mode work without breaking historical names. - prefix_to_server: Dict[str, MCPServer] = {} + prefix_to_server: dict[str, MCPServer] = {} for server in registry_servers: for known_prefix in iter_known_server_prefixes(server): normalised = normalize_server_name(known_prefix) @@ -3734,7 +4569,7 @@ class MCPServerManager: verbose_logger.info(f"Found {len(db_mcp_servers)} MCP servers in database") previous_registry = self.registry - new_registry: Dict[str, MCPServer] = {} + new_registry: dict[str, MCPServer] = {} # Stage one: build every server. Stage two assigns short prefixes # against the *full* set so dedup is deterministic regardless of @@ -3769,6 +4604,7 @@ class MCPServerManager: # (if any) so the prefix is stable across reloads. if existing_server is not None and existing_server.short_prefix: new_server.short_prefix = existing_server.short_prefix + _carry_forward_resolved_oauth_endpoints(new_server=new_server, previous_server=existing_server) new_registry[server.server_id] = new_server except Exception as e: verbose_logger.exception( @@ -3780,7 +4616,7 @@ class MCPServerManager: # Assign short prefixes against the full candidate set without # publishing the staged registry to concurrent callers. - registered_registry: Dict[str, MCPServer] = {} + registered_registry: dict[str, MCPServer] = {} registered_openapi_tools = False for server_id, new_server in new_registry.items(): try: @@ -3806,7 +4642,7 @@ class MCPServerManager: verbose_logger.debug("MCP registry refreshed (%s servers in registry)", len(registered_registry)) - def get_mcp_servers_from_ids(self, server_ids: List[str]) -> List[MCPServer]: + def get_mcp_servers_from_ids(self, server_ids: list[str]) -> list[MCPServer]: servers = [] registry = self.get_registry() for server in registry.values(): @@ -3814,7 +4650,7 @@ class MCPServerManager: servers.append(server) return servers - def _get_general_settings(self) -> Dict[str, Any]: + def _get_general_settings(self) -> dict[str, Any]: """Get general_settings, importing lazily to avoid circular imports.""" try: from litellm.proxy.proxy_server import ( @@ -3857,7 +4693,7 @@ class MCPServerManager: return server return None - def get_public_mcp_servers(self) -> List[MCPServer]: + def get_public_mcp_servers(self) -> list[MCPServer]: """ Return the MCP servers published to the AI Hub via /v1/mcp/make_public. @@ -3887,7 +4723,7 @@ class MCPServerManager: if server.available_on_public_internet or server.server_id in public_ids ] - def expand_permission_list(self, identifiers: List[str]) -> List[str]: + def expand_permission_list(self, identifiers: list[str]) -> list[str]: """ Expand a permission list of server_ids/names/aliases into concrete server_ids against the current region's config + DB registry union. @@ -3903,12 +4739,12 @@ class MCPServerManager: if not identifiers: return [] registry = self.get_registry() - expanded: Set[str] = set() + expanded: set[str] = set() for identifier in identifiers: if identifier in registry: expanded.add(identifier) continue - matches: List[str] = [ + matches: list[str] = [ server_id for server_id, server in registry.items() if server.alias == identifier or server.server_name == identifier or server.name == identifier @@ -3929,8 +4765,8 @@ class MCPServerManager: def expand_tool_permissions( self, - tool_permissions: Optional[Dict[str, List[str]]], - ) -> Dict[str, List[str]]: + tool_permissions: Optional[dict[str, list[str]]], + ) -> dict[str, list[str]]: """ Rewrite an ``mcp_tool_permissions`` dict keyed by id/name/alias so every key is a concrete server_id where possible. Tool lists from @@ -3945,7 +4781,7 @@ class MCPServerManager: """ if not tool_permissions: return {} - result: Dict[str, List[str]] = {} + result: dict[str, list[str]] = {} for key, tools in tool_permissions.items(): for server_id in self.expand_permission_list([key]): result.setdefault(server_id, []).extend(tools or []) @@ -3986,7 +4822,7 @@ class MCPServerManager: return server return None - def get_filtered_registry(self, client_ip: Optional[str] = None) -> Dict[str, MCPServer]: + def get_filtered_registry(self, client_ip: Optional[str] = None) -> dict[str, MCPServer]: """ Get registry filtered by client IP access control. @@ -4146,16 +4982,23 @@ class MCPServerManager: authorization_url=server.authorization_url, token_url=server.token_url, registration_url=server.registration_url, + oauth2_flow=server.oauth2_flow, + dcr_bridge=server.dcr_bridge, + token_exchange_endpoint=server.token_exchange_endpoint, + audience=server.audience, + subject_token_type=server.subject_token_type, + token_exchange_profile=server.token_exchange_profile, allow_all_keys=server.allow_all_keys, instructions=server.instructions, timeout=server.timeout, + max_concurrent_requests=server.max_concurrent_requests, ) async def get_all_mcp_servers_with_health_and_teams( self, user_api_key_auth: Optional[UserAPIKeyAuth] = None, - server_ids: Optional[List[str]] = None, - ) -> List[LiteLLM_MCPServerTable]: + server_ids: Optional[list[str]] = None, + ) -> list[LiteLLM_MCPServerTable]: """ Get all MCP servers that the user has access to, with health status and team information. @@ -4184,7 +5027,7 @@ class MCPServerManager: async def get_all_allowed_mcp_servers( self, user_api_key_auth: Optional[UserAPIKeyAuth] = None, - ) -> List[LiteLLM_MCPServerTable]: + ) -> list[LiteLLM_MCPServerTable]: """ Get all MCP servers that the user has access to. @@ -4197,7 +5040,7 @@ class MCPServerManager: # Get allowed server IDs allowed_server_ids = await self.get_allowed_mcp_servers(user_api_key_auth) - list_mcp_servers: List[LiteLLM_MCPServerTable] = [] + list_mcp_servers: list[LiteLLM_MCPServerTable] = [] for server_id in allowed_server_ids: server = self.get_mcp_server_by_id(server_id) @@ -4212,8 +5055,8 @@ class MCPServerManager: @staticmethod def _env_vars_to_models( - env_vars: Optional[List[Dict[str, Any]]], - ) -> Optional[List[MCPEnvVar]]: + env_vars: Optional[list[dict[str, Any]]], + ) -> Optional[list[MCPEnvVar]]: if env_vars is None: return None return [MCPEnvVar.model_validate(env_var) for env_var in env_vars] @@ -4233,6 +5076,8 @@ class MCPServerManager: teams=[], mcp_access_groups=server.access_groups or [], allowed_tools=server.allowed_tools or [], + tool_name_to_display_name=server.tool_name_to_display_name, + tool_name_to_description=server.tool_name_to_description, extra_headers=server.extra_headers or [], mcp_info=server.mcp_info, static_headers=server.static_headers, @@ -4246,33 +5091,40 @@ class MCPServerManager: authorization_url=server.authorization_url, token_url=server.token_url, registration_url=server.registration_url, + oauth2_flow=server.oauth2_flow, + token_exchange_endpoint=server.token_exchange_endpoint, + audience=server.audience, + subject_token_type=server.subject_token_type, + token_exchange_profile=server.token_exchange_profile, allow_all_keys=server.allow_all_keys, available_on_public_internet=server.available_on_public_internet, delegate_auth_to_upstream=server.delegate_auth_to_upstream, oauth_passthrough=getattr(server, "oauth_passthrough", False), + dcr_bridge=server.dcr_bridge, is_byok=server.is_byok, byok_description=server.byok_description, byok_api_key_help_url=server.byok_api_key_help_url, source_url=server.source_url, instructions=server.instructions, timeout=server.timeout, + max_concurrent_requests=server.max_concurrent_requests, ) - async def get_all_mcp_servers_unfiltered(self) -> List[LiteLLM_MCPServerTable]: + async def get_all_mcp_servers_unfiltered(self) -> list[LiteLLM_MCPServerTable]: """Return all MCP servers from registry without applying access controls.""" registry = self.get_registry() if not registry: return [] - servers: List[LiteLLM_MCPServerTable] = [] + servers: list[LiteLLM_MCPServerTable] = [] for server in registry.values(): servers.append(self._build_mcp_server_table(server)) return servers async def get_all_mcp_servers_with_health_unfiltered( - self, server_ids: Optional[List[str]] = None - ) -> List[LiteLLM_MCPServerTable]: + self, server_ids: Optional[list[str]] = None + ) -> list[LiteLLM_MCPServerTable]: """Return health info for all servers in registry regardless of user access.""" registry = self.get_registry() @@ -4289,7 +5141,7 @@ class MCPServerManager: return await self._run_health_checks(target_server_ids) - async def _run_health_checks(self, target_server_ids: List[str]) -> List[LiteLLM_MCPServerTable]: + async def _run_health_checks(self, target_server_ids: list[str]) -> list[LiteLLM_MCPServerTable]: if not target_server_ids: return [] diff --git a/litellm/proxy/_experimental/mcp_server/oauth2_flow_backfill.py b/litellm/proxy/_experimental/mcp_server/oauth2_flow_backfill.py new file mode 100644 index 00000000000..02cec2475e2 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/oauth2_flow_backfill.py @@ -0,0 +1,155 @@ +"""Startup backfill for oauth2 MCP server rows persisted before oauth2_flow was written. + +Rows created before the write-side stamps (DCR persist, UI create, REST create) carry a +null ``oauth2_flow`` and rely on read-time field-shape inference, which cannot tell a +DCR-registered interactive server from an M2M server unless endpoint discovery succeeds +first. This backfill classifies each null row once, at rest, using signals inference +never had, and persists the result so the read path never has to infer again. + +Signal order, strongest first: + +1. Per-user OAuth token rows exist for the server: only the interactive flow mints + per-user tokens, so this is definitive and immune to the discovery trap. BYOK API + keys share the same table (``LiteLLM_MCPUserCredentials``), so only rows whose + payload decodes as a ``type: oauth2`` token count as proof; bare keys and + undecodable rows prove nothing about the flow. +2. ``authorization_url`` persisted: interactive needs a user-facing authorization + endpoint; M2M (RFC 6749 section 4.4) never has one. +3. ``registration_url`` persisted: dynamic client registration (RFC 7591) exists to mint + clients for the interactive flow; M2M servers are configured with static credentials. +4. ``token_url`` plus decryptable ``client_id`` and ``client_secret``: ambiguous, left + unstamped. The shape is shared by M2M servers and DCR-registered interactive servers + whose authorization endpoint lives only in discovery (registered but never signed + in), so stamping client_credentials here could permanently route per-user traffic + through the proxy's stored client credential. The row keeps working through the + request-time backstop and a warning names it with the one-line fix (set oauth2_flow + via the dashboard or ``PUT /v1/mcp/server``); a completed interactive sign-in also + heals it via rule 1 at the next boot. +5. Anything else is interactive: matching how ``needs_user_oauth_token`` treats a null + flow, so the stamp never changes runtime routing for rows no rule recognizes. + +The backfill never stamps client_credentials: M2M is asserted by a human (config +requires it, the API accepts it, the dashboard sets it), mirroring the config-level +validation error. Runs before the first registry load on every boot and is idempotent: +a healed fleet has no null rows and the backfill exits after one query. +""" + +import json +from collections import Counter +from typing import Any, Literal, Optional + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._experimental.mcp_server.db import _decode_oauth_payload, decrypt_credentials +from litellm.proxy.utils import PrismaClient +from litellm.types.mcp import MCPCredentials + +OAuth2Flow = Literal["client_credentials", "authorization_code"] +BackfillRule = Literal[ + "per_user_tokens", + "authorization_url", + "registration_url", + "ambiguous_m2m_shape", + "interactive_default", +] + +_BACKFILL_AUDIT_ACTOR = "oauth2_flow_backfill" + + +def _decrypted_credentials(raw_credentials: Any) -> Optional[MCPCredentials]: + if raw_credentials is None: + return None + if isinstance(raw_credentials, str): + try: + parsed = json.loads(raw_credentials) + except (ValueError, TypeError): + return None + else: + parsed = raw_credentials + if not isinstance(parsed, dict): + return None + return decrypt_credentials(credentials=dict(parsed)) + + +def classify_null_flow_row( + *, + has_per_user_tokens: bool, + authorization_url: Optional[str], + registration_url: Optional[str], + token_url: Optional[str], + credentials: Optional[MCPCredentials], +) -> tuple[Optional[OAuth2Flow], BackfillRule]: + if has_per_user_tokens: + return "authorization_code", "per_user_tokens" + if authorization_url: + return "authorization_code", "authorization_url" + if registration_url: + return "authorization_code", "registration_url" + if token_url and credentials and credentials.get("client_id") and credentials.get("client_secret"): + return None, "ambiguous_m2m_shape" + return "authorization_code", "interactive_default" + + +async def backfill_null_oauth2_flows(prisma_client: PrismaClient) -> dict[BackfillRule, int]: + """Classify every ``auth_type=oauth2`` row whose ``oauth2_flow`` is null; stamp the provable + ones, warn on the ambiguous ones, and return counts per rule.""" + null_rows: list[Any] = await prisma_client.db.litellm_mcpservertable.find_many( + where={"auth_type": "oauth2", "oauth2_flow": None}, + ) + if not null_rows: + return {} + + server_ids = [row.server_id for row in null_rows] + token_rows: list[Any] = await prisma_client.db.litellm_mcpusercredentials.find_many( + where={"server_id": {"in": server_ids}}, + ) + server_ids_with_oauth_tokens: set[str] = { + token_row.server_id for token_row in token_rows if _decode_oauth_payload(token_row.credential_b64) is not None + } + + classified = tuple( + ( + row, + classify_null_flow_row( + has_per_user_tokens=row.server_id in server_ids_with_oauth_tokens, + authorization_url=row.authorization_url, + registration_url=row.registration_url, + token_url=row.token_url, + credentials=_decrypted_credentials(row.credentials), + ), + ) + for row in null_rows + ) + + for row, (flow, rule) in classified: + if flow is None: + verbose_proxy_logger.warning( + "oauth2_flow backfill: server_id=%s is ambiguous (client credentials + token_url, " + "no interactive signal); left unstamped. Set oauth2_flow explicitly via the " + "dashboard or PUT /v1/mcp/server: client_credentials if this server is M2M, or " + "complete an interactive sign-in and it will be stamped authorization_code at the " + "next boot.", + row.server_id, + ) + else: + verbose_proxy_logger.info( + "oauth2_flow backfill: server_id=%s stamped %s (rule=%s)", + row.server_id, + flow, + rule, + ) + + stamped_flows = {flow for _, (flow, _) in classified if flow is not None} + for stamped_flow in stamped_flows: + server_ids_for_flow = [row.server_id for row, (row_flow, _) in classified if row_flow == stamped_flow] + await prisma_client.db.litellm_mcpservertable.update_many( + where={"server_id": {"in": server_ids_for_flow}, "oauth2_flow": None}, + data={"oauth2_flow": stamped_flow, "updated_by": _BACKFILL_AUDIT_ACTOR}, + ) + + counts: dict[BackfillRule, int] = dict(Counter(rule for _, (_, rule) in classified)) + verbose_proxy_logger.info( + "oauth2_flow backfill: processed %d oauth2 server row(s): %s", + len(null_rows), + counts, + ) + return counts diff --git a/litellm/proxy/_experimental/mcp_server/oauth_utils.py b/litellm/proxy/_experimental/mcp_server/oauth_utils.py index 4d5813dbc5b..6edb22dd858 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth_utils.py +++ b/litellm/proxy/_experimental/mcp_server/oauth_utils.py @@ -129,7 +129,7 @@ def get_request_base_url(request: Request) -> str: if x_forwarded_port and ":" not in netloc: netloc = f"{netloc}:{x_forwarded_port}" - return urlunparse((scheme, netloc, parsed.path, "", "", "")) + return urlunparse((scheme, _strip_default_port(scheme, netloc), parsed.path, "", "", "")) def validate_loopback_redirect_uri(redirect_uri: str) -> None: diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index 815fc2ba29d..e87e8081ced 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -12,7 +12,7 @@ every other mode so the caller defers to v1 (parity-safe); it grows one branch p from __future__ import annotations import base64 -from typing import TYPE_CHECKING, NoReturn, Optional +from typing import TYPE_CHECKING, Literal, NoReturn, Optional from fastapi import HTTPException from pydantic import SecretStr @@ -23,11 +23,13 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AuthorizationCodeConfig, CredError, NoneConfig, + PassthroughConfig, ServerSpec, SharedKey, Subject, + TokenExchangeConfig, ) -from litellm.types.mcp import MCPAuth +from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE, MCPAuth if TYPE_CHECKING: from litellm.proxy._types import UserAPIKeyAuth @@ -61,8 +63,10 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]: an ``assert_never`` tail, so a newly added auth mode fails the type gate here until it is explicitly mapped or explicitly deferred, rather than silently falling through to v1. Live modes: ``none``, the static-header family (``api_key`` plus the Authorization schemes, - all shared-key), and ``oauth2`` per-user tokens (``authorization_code``); client_credentials - (M2M), delegated/passthrough oauth2, token exchange, and SigV4 return None and stay on v1. + all shared-key), ``oauth2`` per-user tokens (``authorization_code``), ``oauth2_token_exchange`` + (OBO), and the client-forwarded token modes ``true_passthrough`` / ``oauth_delegate`` + (``PassthroughConfig``); client_credentials (M2M), delegated/passthrough oauth2, and SigV4 + return None and stay on v1. """ if server.is_byok: return None # per-user BYOK source not migrated yet -> defer to v1 (any auth_type) @@ -92,11 +96,49 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]: ) # client_credentials (M2M) and delegate/passthrough oauth2 stay on v1 return None - case MCPAuth.oauth2_token_exchange | MCPAuth.aws_sigv4: - return None # token exchange and SigV4 are not migrated yet -> defer to v1 + case MCPAuth.true_passthrough | MCPAuth.oauth_delegate: + return ServerSpec(server_id=server.server_id, resource=resource, config=PassthroughConfig()) + case MCPAuth.oauth2_token_exchange: + return _token_exchange_spec(server, resource) + case MCPAuth.aws_sigv4: + return None # SigV4 is not migrated yet -> defer to v1 assert_never(auth_type) +def _token_exchange_spec(server: MCPServer, resource: str) -> Optional[ServerSpec]: + """Build a token_exchange (OBO) spec, or defer (None) when it is not OBO-configured. + + An OBO server with ``client_id``/``client_secret`` is owned by the v2 arm even if the + ``token_exchange_endpoint``/``token_url`` is absent: a missing endpoint then fails closed (412) at + the exchanger rather than silently deferring to v1 and connecting unauthenticated, since the + gateway must not guess the IdP or fall back to a weaker source. Without client credentials there is + nothing to own, so the server stays on v1 (parity-safe). ``profile`` selects the wire dialect + (``rfc8693`` default, ``entra_obo`` for Microsoft Entra On-Behalf-Of); an unrecognized value + normalizes to ``rfc8693`` so a bad config value cannot crash spec-building. ``audience`` is + forwarded only when the operator set it; a missing one is omitted, not derived. + """ + endpoint = server.token_exchange_endpoint or server.token_url + if not server.client_id or not server.client_secret: + return None + profile: Literal["rfc8693", "entra_obo"] = ( + "entra_obo" if server.token_exchange_profile == "entra_obo" else "rfc8693" + ) + return ServerSpec( + server_id=server.server_id, + resource=resource, + config=TokenExchangeConfig( + profile=profile, + subject_token_type=server.subject_token_type or DEFAULT_SUBJECT_TOKEN_TYPE, + token_exchange_endpoint=endpoint, + audience=server.audience, + client_id=server.client_id, + client_secret=SecretStr(server.client_secret), + token_endpoint_auth_method=server.token_endpoint_auth_method, + scopes=tuple(server.scopes or ()), + ), + ) + + def _shared_key_spec( server: MCPServer, resource: str, @@ -148,23 +190,75 @@ def raise_public(error: CredError) -> NoReturn: assert_never(error.tag) -def raise_user_oauth_challenge(server: MCPServer) -> NoReturn: +def oauth_protected_resource_path(root_path: str, server: MCPServer) -> str: + """The server's RFC 9728 Protected Resource Metadata path, the shared anchor of both challenges. + + ``root_path`` is the proxy's ``SERVER_ROOT_PATH``, resolved by the caller (the imperative shell) + so this stays a pure function of its inputs; ``"/"`` and ``""`` both mean no prefix. The path is + relative, so it resolves against the caller's own host (correct even behind a reverse proxy). + """ + prefix = "" if root_path == "/" else root_path + name = server.alias or server.server_name or server.name or server.server_id + return f"/.well-known/oauth-protected-resource{prefix}/mcp/{name}" + + +def raise_user_oauth_challenge(server: MCPServer, *, root_path: str) -> NoReturn: """Raise the 401 an ``authorization_code`` server returns at egress when the user has no token. - Points at the server's RFC 9728 Protected Resource Metadata (``resource_metadata``), which names - the upstream authorization server the client must complete OAuth with. The URL is per-server and - relative, so it resolves against the caller's own host (correct even behind a reverse proxy) - without needing request context. The listing-phase 401 still emits the RFC 8414 ``authorization_uri`` - form pending the format unification; both target the same server, so the difference is cosmetic. + Points at the server's RFC 9728 Protected Resource Metadata, which names the upstream + authorization server the client must complete OAuth with. The listing-phase 401 still emits the + RFC 8414 ``authorization_uri`` form pending the format unification; both target the same server, + so the difference is cosmetic. """ - from litellm.proxy.utils import get_server_root_path # noqa: PLC0415 - - root = get_server_root_path() - prefix = "" if root == "/" else root - name = server.alias or server.server_name or server.name or server.server_id - resource_metadata = f"/.well-known/oauth-protected-resource{prefix}/mcp/{name}" + resource_metadata = oauth_protected_resource_path(root_path, server) raise HTTPException( status_code=401, detail="Unauthorized", headers={"WWW-Authenticate": f'Bearer resource_metadata="{resource_metadata}"'}, ) + + +def raise_token_exchange_challenge( + server: MCPServer, + *, + root_path: str, + claims: str | None = None, +) -> NoReturn: + """Raise the RFC 9728 / RFC 6750 challenge an OBO (``token_exchange``) server returns when the + caller's subject token is missing or the IdP rejected it. + + Points at the server's Protected Resource Metadata, whose ``authorization_servers`` names the IdP + the client must SSO with to obtain a subject token; ``error="invalid_token"`` tells a + spec-compliant MCP client to discover that AS and retry with a fresh bearer. Mirrors + ``raise_user_oauth_challenge`` but for the exchange flow: there is no gateway-side browser OAuth — + the client re-authenticates directly with the IdP, and LiteLLM then exchanges the resulting token. + + An IdP step-up rejection (Entra Conditional Access / CAE) passes its ``claims`` blob. Per the + Microsoft claims-challenge format the challenge then uses ``error="insufficient_claims"`` (the + value MSAL-family clients key on) and carries the claims base64-encoded in a ``claims`` parameter + the client replays to the IdP to satisfy the step-up. Without a claims blob the challenge keeps + ``error="invalid_token"`` and is byte-identical to the static one. Both the error value (one of + two literals) and the base64 claims draw from a fixed alphabet, so nothing from the IdP body + reaches the header unescaped. + """ + resource_metadata = oauth_protected_resource_path(root_path, server) + encoded_claims = base64.b64encode(claims.encode()).decode() if claims else None + error = "insufficient_claims" if encoded_claims else "invalid_token" + error_description = ( + "Step-up authentication required; satisfy the returned claims challenge with the IdP and retry" + if encoded_claims + else "Missing or invalid subject token; authenticate with the IdP and retry" + ) + www_authenticate = ", ".join( + ( + f'Bearer resource_metadata="{resource_metadata}"', + f'error="{error}"', + f'error_description="{error_description}"', + *((f'claims="{encoded_claims}"',) if encoded_claims else ()), + ) + ) + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"WWW-Authenticate": www_authenticate}, + ) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py new file mode 100644 index 00000000000..c352f3a683e --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py @@ -0,0 +1,243 @@ +"""Producer and consumer helpers for the DCR-bridge ``oauth_delegate`` envelope. + +A DCR-bridge ``oauth_delegate`` client presents ONE bearer that is a litellm-signed +envelope (see :mod:`.envelope`) carrying both a litellm identity and the upstream OAuth +token. The gateway token endpoint mints it (producer) at OAuth issuance, and at the MCP +admission edge the gateway derives the envelope keys from the proxy ``master_key``, opens +it, admits the request under the recovered identity, and forwards the inner upstream token +to the upstream MCP server (consumer). This module is the pure surface for both sides; the +token-endpoint and admission wiring live in their respective call sites. +""" + +import hashlib +from datetime import datetime +from functools import lru_cache +from typing import Literal, TypeAlias + +from pydantic import BaseModel, ConfigDict, SecretStr + +from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( + EnvelopeIdentity, + EnvelopeKeys, + EnvelopeMintError, + OpenedEnvelope, + OpenedRefreshEnvelope, + RefreshCredential, + SealedEnvelope, + UpstreamTokenGrant, + is_envelope, + is_refresh_envelope, + mint_envelope, + mint_refresh_envelope, + open_envelope, + open_refresh_envelope, +) + +_SIGNING_KEY_DOMAIN = b"litellm-mcp-bridge:envelope-signing:" +_ENCRYPTION_KEY_DOMAIN = b"litellm-mcp-bridge:envelope-encryption:" + +# scrypt work factors (RFC 7914). n=2**15 with r=8/p=1 costs ~50ms and ~32MB per derivation, which +# makes offline guessing of a candidate master key memory-hard rather than a bare hash comparison. +_SCRYPT_N = 2**15 +_SCRYPT_R = 8 +_SCRYPT_P = 1 +# scrypt's working-set is ~128 * N * r * p bytes; cap at twice that so the maxmem ceiling scales +# with every work factor and a future p or r bump does not trip "memory limit exceeded". +_SCRYPT_MAXMEM = 128 * _SCRYPT_N * _SCRYPT_R * _SCRYPT_P * 2 +_DERIVED_KEY_BYTES = 32 + + +@lru_cache(maxsize=8) +def envelope_keys_from_master_key(master_key: str) -> EnvelopeKeys: + """Derive the envelope signing and encryption keys from the proxy master key. + + A memory-hard scrypt KDF (RFC 7914) over two distinct domain-label salts yields two + independent 256-bit subkeys from the one secret, so the producer (mint) and consumer + (open) agree on keys without persisting any. scrypt is used rather than a bare hash or + HMAC so that a captured envelope is not a cheap offline oracle for the master key: each + candidate guess costs a full memory-hard derivation, which is what protects a deployment + whose master key is weaker than it should be. The result is cached (the master key is + fixed for a process), so the KDF runs once per key and adds nothing to the per-request + admission path. The derivation is deterministic; rotating ``master_key`` invalidates + every outstanding envelope, which is the intended behavior for a signing-key change. + """ + signing = hashlib.scrypt( + master_key.encode(), + salt=_SIGNING_KEY_DOMAIN, + n=_SCRYPT_N, + r=_SCRYPT_R, + p=_SCRYPT_P, + maxmem=_SCRYPT_MAXMEM, + dklen=_DERIVED_KEY_BYTES, + ).hex() + encryption = hashlib.scrypt( + master_key.encode(), + salt=_ENCRYPTION_KEY_DOMAIN, + n=_SCRYPT_N, + r=_SCRYPT_R, + p=_SCRYPT_P, + maxmem=_SCRYPT_MAXMEM, + dklen=_DERIVED_KEY_BYTES, + ).hex() + return EnvelopeKeys(signing_key=SecretStr(signing), encryption_key=SecretStr(encryption)) + + +def build_bridge_token_response( + identity: EnvelopeIdentity, + grant: UpstreamTokenGrant, + keys: EnvelopeKeys, + now: datetime, +) -> SealedEnvelope | EnvelopeMintError: + """Seal ``grant`` for ``identity`` into the client-held bearer the token endpoint returns. + + The producer mirror of :func:`resolve_bridge_envelope`: a thin, pure wrapper over + :func:`mint_envelope` that returns the sealed envelope, or the mint error as a value + (an oversized grant) for the caller to map onto an OAuth error response. + """ + return mint_envelope(identity, grant, keys, now) + + +def build_bridge_refresh_token_response( + identity: EnvelopeIdentity, + refresh: RefreshCredential, + keys: EnvelopeKeys, + now: datetime, +) -> SealedEnvelope | EnvelopeMintError: + """Seal ``refresh`` for ``identity`` into the long-lived refresh envelope the token endpoint returns + alongside the access envelope, so the client can renew without re-authenticating. A thin, pure + wrapper over :func:`mint_refresh_envelope`; returns the mint error as a value for the caller to map. + """ + return mint_refresh_envelope(identity, refresh, keys, now) + + +class BridgeRefreshOpened(BaseModel): + """A valid refresh envelope presented to the token endpoint: the identity to re-validate and renew + under, and the upstream refresh grant to exchange.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["opened"] = "opened" + identity: EnvelopeIdentity + refresh: RefreshCredential + + +class BridgeRefreshInvalid(BaseModel): + """The presented refresh grant is not a valid refresh envelope for this server (not refresh-shaped, + will not open, or minted for a different server); the token endpoint fails the refresh closed.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["invalid"] = "invalid" + + +BridgeRefreshResult: TypeAlias = BridgeRefreshOpened | BridgeRefreshInvalid + + +def open_bridge_refresh_envelope( + refresh_value: str, + keys: EnvelopeKeys, + now: datetime, + expected_server_id: str, +) -> BridgeRefreshResult: + """Open a refresh envelope a bridge ``oauth_delegate`` client presented on a refresh_token grant. + + The token-endpoint mirror of :func:`resolve_bridge_envelope`: strips an optional ``Bearer`` scheme, + then returns ``BridgeRefreshOpened`` with the recovered identity and upstream refresh grant, or + ``BridgeRefreshInvalid`` for anything that is not a valid refresh envelope for this server. Never + raises; total over hostile input via :func:`open_refresh_envelope`. ``expected_server_id`` binds the + envelope to the server the request targets, so a refresh envelope minted for one server cannot renew + against another. A raw upstream refresh token (not envelope-shaped) is ``BridgeRefreshInvalid``: this + mode never hands the client a bare upstream refresh token, so it must never accept one. + """ + candidate = _strip_bearer(refresh_value) + if not is_refresh_envelope(candidate): + return BridgeRefreshInvalid() + opened = open_refresh_envelope(candidate, keys, now) + if not isinstance(opened, OpenedRefreshEnvelope): + return BridgeRefreshInvalid() + if opened.identity.server_id != expected_server_id: + return BridgeRefreshInvalid() + return BridgeRefreshOpened(identity=opened.identity, refresh=opened.refresh) + + +class NotBridgeEnvelope(BaseModel): + """The bearer is not an envelope; admission continues on its normal path.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["not_bridge_envelope"] = "not_bridge_envelope" + + +class BridgeEnvelopeAdmitted(BaseModel): + """A valid envelope: the identity to admit under and the full upstream ``Authorization`` + value (``token_type access_token``) to forward to the upstream MCP server.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["admitted"] = "admitted" + identity: EnvelopeIdentity + upstream_authorization: SecretStr + + +class BridgeEnvelopeInvalid(BaseModel): + """The bearer is envelope-shaped but did not open (expired, tampered, wrong key); + admission must fail closed rather than fall through to normal validation.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["invalid"] = "invalid" + + +BridgeEnvelopeResult: TypeAlias = NotBridgeEnvelope | BridgeEnvelopeAdmitted | BridgeEnvelopeInvalid + + +def _strip_bearer(value: str) -> str: + parts = value.split(None, 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1] + return value + + +def is_bridge_envelope_shaped(authorization_value: str) -> bool: + """Cheap, keyless test that an ``Authorization`` value carries an envelope of either kind (optional + ``Bearer`` scheme stripped). The admission edge engages the bridge arm for an access envelope (to + admit) and for a refresh envelope (to reject it explicitly, since a refresh credential is never + usable at the tool-call edge); a plain upstream bearer falls through to normal oauth2 admission.""" + candidate = _strip_bearer(authorization_value) + return is_envelope(candidate) or is_refresh_envelope(candidate) + + +def resolve_bridge_envelope( + authorization_value: str, + keys: EnvelopeKeys, + now: datetime, + expected_server_id: str, +) -> BridgeEnvelopeResult: + """Classify an ``Authorization`` value presented to a bridge ``oauth_delegate`` server. + + Strips an optional ``Bearer`` scheme, then returns ``NotBridgeEnvelope`` for a + non-envelope bearer (normal admission continues), ``BridgeEnvelopeAdmitted`` with the + recovered identity and the upstream ``Authorization`` value to forward for a valid + envelope, and ``BridgeEnvelopeInvalid`` for an envelope-shaped bearer that will not + open. Never raises: it is total over hostile input via :func:`open_envelope`. + + A refresh envelope is ``BridgeEnvelopeInvalid`` here: it is a valid gateway credential but only ever + presented back to the token endpoint, never usable to authenticate a tool call, so admission must + fail it closed rather than let it fall through to another arm. + + ``expected_server_id`` is the ``server_id`` of the MCP server the request targets; an + opened envelope whose sealed ``server_id`` does not match is rejected as + ``BridgeEnvelopeInvalid``. Binding here (rather than leaving it to the caller) prevents + replaying an envelope minted for one server against another, which would forward the + first server's upstream credential across a server boundary. ``server_id`` is not a + secret (the caller targets that server), so a plain equality check is sufficient and, + unlike ``hmac.compare_digest`` on ``str``, does not raise on a non-ASCII server_id. + """ + candidate = _strip_bearer(authorization_value) + if is_refresh_envelope(candidate): + return BridgeEnvelopeInvalid() + if not is_envelope(candidate): + return NotBridgeEnvelope() + opened = open_envelope(candidate, keys, now) + if not isinstance(opened, OpenedEnvelope): + return BridgeEnvelopeInvalid() + if opened.identity.server_id != expected_server_id: + return BridgeEnvelopeInvalid() + grant = opened.grant + upstream_authorization = f"{grant.token_type} {grant.access_token.get_secret_value()}" + return BridgeEnvelopeAdmitted(identity=opened.identity, upstream_authorization=SecretStr(upstream_authorization)) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py new file mode 100644 index 00000000000..9118a3e129d --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py @@ -0,0 +1,568 @@ +"""Client-held sealed envelope for the oauth_delegate DCR bridge. + +A DCR-bridge client holds ONE bearer that must carry BOTH a litellm identity and the +upstream OAuth grant, with zero server-side storage. The gateway token endpoint mints a +litellm-signed envelope (:func:`mint_envelope`); the MCP edge validates it, recovers the +identity claims and the inner upstream grant (:func:`open_envelope`), and forwards the +inner access token upstream. This module is pure and unwired: it imports nothing from +endpoint or edge code, reads no proxy globals, and takes all key material and the clock +as explicit parameters. + +Wire shape: ``llm_env_`` + an HS256 JWT (same signing approach as the BYOK session +bearer in ``byok_oauth_endpoints.py``). Registered claims are ``iss``/``iat``/``exp``; +custom claims are ``server_id``, ``key_hash``, and ``grant``, where ``grant`` is the +upstream token grant serialized to JSON, encrypted with the repo's symmetric +encryption helpers (``encrypt_value``/``decrypt_value`` from +``encrypt_decrypt_utils`` — the same family ``encrypt_value_helper`` applies to +persisted DCR credentials), and base64url-encoded, so the inner token never appears +in plaintext anywhere in the envelope. + +Failures are values: :func:`open_envelope` returns one of the frozen +``EnvelopeOpenError`` variants (discriminated on ``tag``) for invalid, expired, +tampered, or undecryptable input, and :func:`mint_envelope` returns +``EnvelopeTooLarge`` for oversized grants. Error values carry tags and sizes only, +never token material. + +The pydantic input models reject programmer errors at construction (e.g. a +non-positive ``expires_in`` or an empty required field). :func:`open_envelope` is +additionally total over hostile, attacker-controlled input: it never raises, only +returns an ``EnvelopeOpenError``. :func:`mint_envelope` operates on a +gateway-supplied grant (an upstream IdP's UTF-8 JSON token response), so it does not +defend against non-UTF-8 field content that cannot survive JSON parsing; its only +value-typed failure is ``EnvelopeTooLarge``. +""" + +from __future__ import annotations + +import base64 +from datetime import datetime, timedelta +from typing import Literal, TypeAlias + +import jwt +from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError + +from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value, encrypt_value + +ENVELOPE_PREFIX = "llm_env_" +"""Marker prefix on every serialized ACCESS envelope so the edge can cheaply tell an envelope +from a raw upstream token before doing any cryptography.""" + +REFRESH_ENVELOPE_PREFIX = "llm_refresh_" +"""Marker prefix on every serialized REFRESH envelope. A distinct prefix keeps the two credentials +routable without crypto and, together with the signed ``kind`` claim, stops one from being presented +where the other is expected: a refresh envelope carries a long-lived upstream refresh token and is only +ever presented back to the token endpoint, never forwarded upstream on a tool call.""" + +ENVELOPE_ISSUER = "litellm-mcp-bridge" +"""``iss`` claim stamped into every envelope and required back on open.""" + +MAX_ENVELOPE_TTL_SECONDS = 3600 +"""Hard ceiling on ACCESS envelope lifetime. ``exp`` is ``min(upstream expires_in, this cap)`` +(the cap alone when the upstream omits ``expires_in``), matching the 1h lifetime of the +BYOK session bearer this module's signing approach is borrowed from: a client-held +credential should never outlive a bounded window even when the upstream token does.""" + +MAX_REFRESH_ENVELOPE_TTL_SECONDS = 1209600 +"""Hard ceiling on REFRESH envelope lifetime (14 days). A refresh envelope only renews the short-lived +access envelope, and each renewal re-validates the sealed litellm key (revocation gates it) and is +re-minted with a fresh window, so the practical bound is idle time, not a fixed session. ``exp`` is +``min(upstream refresh_expires_in, this cap)`` (the cap alone when the upstream omits it); if the +upstream refresh token dies first, the next renewal simply fails at the upstream and the client +re-authenticates. The value is deliberately far shorter than a typical upstream refresh-token lifetime +so a leaked refresh envelope is bounded even if the upstream would have honoured it for longer.""" + +MAX_ENVELOPE_BYTES = 12288 +"""Size cap on the final serialized envelope (prefix + JWT, in bytes). Upstream JWTs +commonly run 2-4KB; base64 plus encryption overhead roughly doubles that inside the +envelope, and common proxy/server header limits sit around 16KB total. 12288 leaves +comfortable headroom for a large upstream token while keeping the envelope safely +transmittable as a single Authorization header. Oversized grants are rejected with a +typed error, never truncated.""" + +_ENVELOPE_JWT_ALGORITHM = "HS256" + +EnvelopeKind = Literal["access", "refresh"] +"""Which credential an envelope is. Stamped into the signed claims and required to match on open, so a +signature-valid envelope of one kind cannot be replayed as the other even if its wire prefix is swapped +(the prefix is not part of the signed payload; this claim is).""" + + +EnvelopeSubjectType: TypeAlias = Literal["key_hash", "user_id"] +"""Discriminator for what litellm principal the envelope binds the grant to. + +``key_hash`` is a hashed virtual key (the scripted two-header client mints under the key it +presents at the token endpoint); ``user_id`` is a litellm user subject (the interactive DCR +client mints under the SSO-authenticated user, which is the only identity that browser login +yields). Admission reloads a key record for the first and a user record for the second, then +runs both through the same live-policy gate, so team/org/budget/revocation enforcement is +identical either way.""" + + +class EnvelopeIdentity(BaseModel): + """The litellm principal the envelope binds the inner grant to. + + ``subject`` is the principal identifier and ``subject_type`` says how to resolve it: a + hashed litellm key (``key_hash``) or a litellm user id (``user_id``), never a raw + credential (and the edge rejects a bare hash or id presented as a bearer). Admission + reloads the live record by it, so the principal's current team/org restrictions and its + revocation state are enforced at use time rather than frozen at mint time. ``server_id`` + binds the envelope to one MCP server so it cannot be replayed across a server boundary. + """ + + model_config = ConfigDict(frozen=True) + server_id: str = Field(min_length=1) + subject_type: EnvelopeSubjectType + subject: str = Field(min_length=1) + + +def key_hash_identity(server_id: str, key_hash: str) -> EnvelopeIdentity: + """The identity for the scripted client that mints under a presented virtual key.""" + return EnvelopeIdentity(server_id=server_id, subject_type="key_hash", subject=key_hash) + + +def user_identity(server_id: str, user_id: str) -> EnvelopeIdentity: + """The identity for the interactive DCR client that mints under its SSO user subject.""" + return EnvelopeIdentity(server_id=server_id, subject_type="user_id", subject=user_id) + + +class UpstreamTokenGrant(BaseModel): + """The upstream OAuth token response fields sealed inside the envelope. + + ``expires_in`` must be positive when present; a non-positive value is a programmer + error rejected at construction. Token fields are ``SecretStr`` so reprs never leak + them. + """ + + model_config = ConfigDict(frozen=True) + access_token: SecretStr = Field(min_length=1) + token_type: str = Field(min_length=1) + refresh_token: SecretStr | None = None + scope: str | None = None + expires_in: int | None = Field(default=None, gt=0) + + +class RefreshCredential(BaseModel): + """The upstream refresh grant sealed inside a refresh envelope. + + Only the refresh token (plus the scope to re-request and the refresh token's own lifetime, when the + upstream reports it) is sealed; the access token is never in a refresh envelope. ``refresh_token`` is + a ``SecretStr`` so reprs never leak it, and ``expires_in`` (the refresh token's lifetime, not the + access token's) must be positive when present. + """ + + model_config = ConfigDict(frozen=True) + refresh_token: SecretStr = Field(min_length=1) + scope: str | None = None + expires_in: int | None = Field(default=None, gt=0) + + +class EnvelopeKeys(BaseModel): + """Injected key material: the HS256 signing key and the symmetric encryption key. + + ``signing_key`` must be at least 32 bytes: HS256's HMAC-SHA256 has a 256-bit + security level, RFC 7518 requires a key of at least that size, and a shorter key + makes PyJWT emit ``InsecureKeyLengthWarning``. + """ + + model_config = ConfigDict(frozen=True) + signing_key: SecretStr = Field(min_length=32) + encryption_key: SecretStr = Field(min_length=1) + + +class SealedEnvelope(BaseModel): + """A minted envelope: the client-held bearer value and when it expires.""" + + model_config = ConfigDict(frozen=True) + token: SecretStr + expires_at: datetime + + +class OpenedEnvelope(BaseModel): + """A validated access envelope: the identity it was minted for and the recovered grant.""" + + model_config = ConfigDict(frozen=True) + identity: EnvelopeIdentity + grant: UpstreamTokenGrant + + +class OpenedRefreshEnvelope(BaseModel): + """A validated refresh envelope: the identity it was minted for and the recovered refresh grant.""" + + model_config = ConfigDict(frozen=True) + identity: EnvelopeIdentity + refresh: RefreshCredential + + +class EnvelopeTooLarge(BaseModel): + """The serialized envelope exceeded ``MAX_ENVELOPE_BYTES``; carries sizes only.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["envelope_too_large"] = "envelope_too_large" + size_bytes: int + max_bytes: int + + +EnvelopeMintError: TypeAlias = EnvelopeTooLarge + + +class NotAnEnvelope(BaseModel): + """The candidate does not carry the envelope prefix.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["not_an_envelope"] = "not_an_envelope" + + +class BadSignature(BaseModel): + """The JWT signature does not verify under the provided signing key.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["bad_signature"] = "bad_signature" + + +class Expired(BaseModel): + """The envelope's ``exp`` is not in the future relative to the provided ``now``.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["expired"] = "expired" + + +class MalformedPayload(BaseModel): + """The token is not a well-formed envelope: undecodable JWT, wrong issuer, missing + or mistyped claims, or a decrypted grant that fails validation.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["malformed_payload"] = "malformed_payload" + + +class DecryptFailed(BaseModel): + """The signed ``grant`` blob could not be decrypted under the provided key.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["decrypt_failed"] = "decrypt_failed" + + +EnvelopeOpenError: TypeAlias = NotAnEnvelope | BadSignature | Expired | MalformedPayload | DecryptFailed + + +class _EnvelopeClaims(BaseModel): + """Decoded-claims boundary that pins the exact shape :func:`mint_envelope` emits. + + ``server_id``/``key_hash`` mirror the ``min_length`` constraints of + :class:`EnvelopeIdentity` so any claim set that validates here also constructs an + identity, keeping :func:`open_envelope` raise-free: a correctly signed JWT with an + empty identity claim fails here and maps to ``MalformedPayload``. + + ``strict`` rejects coerced types (``exp: "123"``, ``exp: 123.0``) rather than opening + on them, and ``extra="forbid"`` rejects any claim the gateway never mints (a hostile + ``nbf``/``aud``/... rides along on a re-signed token). Since PyJWT's own ``iat``/ + ``nbf``/``exp`` validators are disabled at decode (they raise on hostile claim types + and, for ``iat``/``nbf``, compare against the wall clock rather than the injected + ``now``), this model is the sole, total type gate for every registered claim. + """ + + model_config = ConfigDict(frozen=True, strict=True, extra="forbid") + iss: str + iat: int + exp: int + kind: EnvelopeKind + server_id: str = Field(min_length=1) + subject_type: EnvelopeSubjectType + subject: str = Field(min_length=1) + grant: str = Field(min_length=1) + + +class _GrantWire(BaseModel): + model_config = ConfigDict(frozen=True) + access_token: str + token_type: str + refresh_token: str | None = None + scope: str | None = None + expires_in: int | None = None + + +class _RefreshWire(BaseModel): + model_config = ConfigDict(frozen=True) + refresh_token: str + scope: str | None = None + expires_in: int | None = None + + +def is_envelope(candidate: str) -> bool: + """Cheap prefix check for an ACCESS envelope so the edge can route envelopes vs raw tokens without + crypto. A refresh envelope has a different prefix and is not an access envelope.""" + return candidate.startswith(ENVELOPE_PREFIX) + + +def is_refresh_envelope(candidate: str) -> bool: + """Cheap prefix check for a REFRESH envelope so the token endpoint can route a refresh grant that + carries an envelope vs a raw upstream refresh token without crypto.""" + return candidate.startswith(REFRESH_ENVELOPE_PREFIX) + + +def mint_envelope( + identity: EnvelopeIdentity, + grant: UpstreamTokenGrant, + keys: EnvelopeKeys, + now: datetime, +) -> SealedEnvelope | EnvelopeMintError: + """Seal ``grant`` for ``identity`` into a client-held envelope. + + ``exp`` is ``min(grant.expires_in, MAX_ENVELOPE_TTL_SECONDS)`` seconds from ``now`` + (the cap alone when ``expires_in`` is absent). Returns ``EnvelopeTooLarge`` when the + serialized envelope exceeds ``MAX_ENVELOPE_BYTES``. + """ + expires_at = now + timedelta(seconds=_envelope_ttl_seconds(grant.expires_in)) + return _seal( + kind="access", + prefix=ENVELOPE_PREFIX, + identity=identity, + grant_blob=_encrypt_grant_blob(_grant_plaintext(grant), keys.encryption_key), + expires_at=expires_at, + signing_key=keys.signing_key, + now=now, + ) + + +def open_envelope( + candidate: str, + keys: EnvelopeKeys, + now: datetime, +) -> OpenedEnvelope | EnvelopeOpenError: + """Validate ``candidate`` and recover the identity and inner grant. + + Never raises for bad input: every invalid, expired, tampered, or undecryptable + candidate maps to a distinct ``EnvelopeOpenError`` variant. The recovered + ``grant.expires_in`` is the value the upstream reported at mint time and is not + re-derived, so it is stale by up to the envelope's lifetime; callers that need a + live remaining lifetime should use ``now`` against the upstream, not this field. + """ + claims = _open_claims(candidate, prefix=ENVELOPE_PREFIX, expected_kind="access", keys=keys, now=now) + if not isinstance(claims, _EnvelopeClaims): + return claims + grant = _decrypt_grant(claims.grant, keys.encryption_key) + if not isinstance(grant, UpstreamTokenGrant): + return grant + return OpenedEnvelope( + identity=EnvelopeIdentity(server_id=claims.server_id, subject_type=claims.subject_type, subject=claims.subject), + grant=grant, + ) + + +def mint_refresh_envelope( + identity: EnvelopeIdentity, + refresh: RefreshCredential, + keys: EnvelopeKeys, + now: datetime, +) -> SealedEnvelope | EnvelopeMintError: + """Seal ``refresh`` for ``identity`` into a long-lived, client-held refresh envelope. + + ``exp`` is ``min(refresh.expires_in, MAX_REFRESH_ENVELOPE_TTL_SECONDS)`` seconds from ``now`` (the + cap alone when the upstream omits the refresh lifetime). Sealing a distinct ``kind="refresh"`` claim + is what keeps a refresh envelope from ever opening as an access credential at the MCP edge. Returns + ``EnvelopeTooLarge`` when the serialized envelope exceeds ``MAX_ENVELOPE_BYTES``. + """ + expires_at = now + timedelta(seconds=_refresh_ttl_seconds(refresh.expires_in)) + return _seal( + kind="refresh", + prefix=REFRESH_ENVELOPE_PREFIX, + identity=identity, + grant_blob=_encrypt_grant_blob(_refresh_plaintext(refresh), keys.encryption_key), + expires_at=expires_at, + signing_key=keys.signing_key, + now=now, + ) + + +def open_refresh_envelope( + candidate: str, + keys: EnvelopeKeys, + now: datetime, +) -> OpenedRefreshEnvelope | EnvelopeOpenError: + """Validate a refresh ``candidate`` and recover the identity and inner refresh grant. + + Total over hostile input exactly like :func:`open_envelope`: every invalid, expired, tampered, + wrong-kind, or undecryptable candidate maps to a distinct ``EnvelopeOpenError`` variant, never a + raise. The ``kind="refresh"`` claim is required, so an access envelope re-prefixed as a refresh one + is rejected as ``MalformedPayload``. + """ + claims = _open_claims(candidate, prefix=REFRESH_ENVELOPE_PREFIX, expected_kind="refresh", keys=keys, now=now) + if not isinstance(claims, _EnvelopeClaims): + return claims + refresh = _decrypt_refresh(claims.grant, keys.encryption_key) + if not isinstance(refresh, RefreshCredential): + return refresh + return OpenedRefreshEnvelope( + identity=EnvelopeIdentity(server_id=claims.server_id, subject_type=claims.subject_type, subject=claims.subject), + refresh=refresh, + ) + + +def _seal( + kind: EnvelopeKind, + prefix: str, + identity: EnvelopeIdentity, + grant_blob: str, + expires_at: datetime, + signing_key: SecretStr, + now: datetime, +) -> SealedEnvelope | EnvelopeTooLarge: + """Sign the claims for either envelope kind and enforce the size cap. Shared by both mints so the + JWT shape, issuer, and size guard cannot drift between access and refresh envelopes.""" + claims = _EnvelopeClaims( + iss=ENVELOPE_ISSUER, + iat=int(now.timestamp()), + exp=int(expires_at.timestamp()), + kind=kind, + server_id=identity.server_id, + subject_type=identity.subject_type, + subject=identity.subject, + grant=grant_blob, + ) + token = prefix + jwt.encode(claims.model_dump(), signing_key.get_secret_value(), algorithm=_ENVELOPE_JWT_ALGORITHM) + size_bytes = len(token.encode("utf-8")) + if size_bytes > MAX_ENVELOPE_BYTES: + return EnvelopeTooLarge(size_bytes=size_bytes, max_bytes=MAX_ENVELOPE_BYTES) + return SealedEnvelope(token=SecretStr(token), expires_at=expires_at) + + +def _open_claims( + candidate: str, + prefix: str, + expected_kind: EnvelopeKind, + keys: EnvelopeKeys, + now: datetime, +) -> _EnvelopeClaims | EnvelopeOpenError: + """Prefix-route, size-bound, signature-verify, kind-check, and expiry-check an attacker-controlled + candidate, shared by both openers so the security gate is identical for access and refresh. Returns + the validated claims or a distinct ``EnvelopeOpenError``; never raises.""" + if not candidate.startswith(prefix): + return NotAnEnvelope() + # UTF-8 byte length is never below character length, so a character count already over the cap + # rejects an oversize candidate in O(1) without encoding it; the exact byte check then runs only on + # candidates already bounded to <= MAX_ENVELOPE_BYTES characters. + if len(candidate) > MAX_ENVELOPE_BYTES: + return MalformedPayload() + if len(candidate.encode("utf-8", "surrogatepass")) > MAX_ENVELOPE_BYTES: + return MalformedPayload() + claims = _decode_claims(candidate.removeprefix(prefix), keys.signing_key) + if not isinstance(claims, _EnvelopeClaims): + return claims + if claims.kind != expected_kind: + return MalformedPayload() + if now.timestamp() >= claims.exp: + return Expired() + return claims + + +def _envelope_ttl_seconds(upstream_expires_in: int | None) -> int: + if upstream_expires_in is None: + return MAX_ENVELOPE_TTL_SECONDS + return min(upstream_expires_in, MAX_ENVELOPE_TTL_SECONDS) + + +def _refresh_ttl_seconds(upstream_refresh_expires_in: int | None) -> int: + if upstream_refresh_expires_in is None: + return MAX_REFRESH_ENVELOPE_TTL_SECONDS + return min(upstream_refresh_expires_in, MAX_REFRESH_ENVELOPE_TTL_SECONDS) + + +def _grant_plaintext(grant: UpstreamTokenGrant) -> str: + wire = _GrantWire( + access_token=grant.access_token.get_secret_value(), + token_type=grant.token_type, + refresh_token=None if grant.refresh_token is None else grant.refresh_token.get_secret_value(), + scope=grant.scope, + expires_in=grant.expires_in, + ) + return wire.model_dump_json(exclude_none=True) + + +def _refresh_plaintext(refresh: RefreshCredential) -> str: + wire = _RefreshWire( + refresh_token=refresh.refresh_token.get_secret_value(), + scope=refresh.scope, + expires_in=refresh.expires_in, + ) + return wire.model_dump_json(exclude_none=True) + + +def _decode_claims( + compact: str, + signing_key: SecretStr, +) -> _EnvelopeClaims | BadSignature | MalformedPayload: + """Verify the HS256 signature and shape of an attacker-controlled compact JWT. + + ``compact`` is fully hostile and bounded to ``MAX_ENVELOPE_BYTES`` by the caller. + PyJWT's ``iat``/``nbf``/``exp`` validators are disabled: they raise on hostile claim + types and, for ``iat``/``nbf``, compare against the wall clock rather than the + injected ``now`` (``exp`` is checked by the caller against ``now``). Apart from a + signature mismatch (``BadSignature``), every decode failure is ``MalformedPayload``: + a non-UTF-8 candidate surfaces as ``UnicodeEncodeError`` (a ``ValueError``), a + non-string registered claim such as ``iss`` as a ``TypeError`` from PyJWT's claim + validators, and a wrong issuer or structurally invalid token as an + ``InvalidTokenError``. ``_EnvelopeClaims`` is the total type gate for the payload. + """ + try: + payload = jwt.decode( + compact, + signing_key.get_secret_value(), + algorithms=[_ENVELOPE_JWT_ALGORITHM], + issuer=ENVELOPE_ISSUER, + options={ + "verify_exp": False, + "verify_iat": False, + "verify_nbf": False, + "require": ["iss", "iat", "exp"], + }, + ) + except jwt.InvalidSignatureError: + return BadSignature() + except (jwt.InvalidTokenError, ValueError, TypeError): + return MalformedPayload() + try: + return _EnvelopeClaims.model_validate(payload) + except ValidationError: + return MalformedPayload() + + +def _encrypt_grant_blob(plaintext: str, encryption_key: SecretStr) -> str: + ciphertext = bytes(encrypt_value(value=plaintext, signing_key=encryption_key.get_secret_value())) + return base64.urlsafe_b64encode(ciphertext).decode("ascii") + + +def _decrypt_grant( + blob: str, + encryption_key: SecretStr, +) -> UpstreamTokenGrant | DecryptFailed | MalformedPayload: + from nacl.exceptions import CryptoError + + try: + plaintext = decrypt_value( + value=base64.urlsafe_b64decode(blob), + signing_key=encryption_key.get_secret_value(), + ) + except (CryptoError, ValueError): + return DecryptFailed() + try: + return UpstreamTokenGrant.model_validate_json(plaintext) + except ValidationError: + return MalformedPayload() + + +def _decrypt_refresh( + blob: str, + encryption_key: SecretStr, +) -> RefreshCredential | DecryptFailed | MalformedPayload: + from nacl.exceptions import CryptoError + + try: + plaintext = decrypt_value( + value=base64.urlsafe_b64decode(blob), + signing_key=encryption_key.get_secret_value(), + ) + except (CryptoError, ValueError): + return DecryptFailed() + try: + return RefreshCredential.model_validate_json(plaintext) + except ValidationError: + return MalformedPayload() diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/oauth_token_store.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/oauth_token_store.py index fd2cb2f3e06..c1c70cf9050 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/oauth_token_store.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/oauth_token_store.py @@ -69,6 +69,17 @@ class OAuthTokenStore(Protocol): async def fetch(self, user_id: str, server_id: str) -> OAuthToken | None: ... +class InvalidatableOAuthTokenStore(OAuthTokenStore, Protocol): + """An ``OAuthTokenStore`` whose cached entry for a ``(user, server)`` pair can be dropped. + + The write side calls ``invalidate`` after a (re)authorization or revocation changes the + credential row, so reads stop serving the replaced token immediately instead of until its + cache TTL. ``CachedOAuthTokenStore`` (the top of the per-user chain) satisfies this. + """ + + async def invalidate(self, user_id: str, server_id: str) -> None: ... + + class TokenRefresher(Protocol): """Mints a fresh token from an expired one and persists it, returning the new token. diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/per_user_oauth_store.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/per_user_oauth_store.py index 3bc10f1a0eb..21001c09f25 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/per_user_oauth_store.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/per_user_oauth_store.py @@ -24,8 +24,8 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.dual_cache_toke ) from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import ( CachedOAuthTokenStore, + InvalidatableOAuthTokenStore, OAuthToken, - OAuthTokenStore, RefreshCoordinator, RefreshingTokenStore, TokenCacheBackend, @@ -51,7 +51,7 @@ if TYPE_CHECKING: _DEFAULT_TTL_SECONDS = 300.0 ServerLookup = Callable[[str], "MCPServer | None"] -StoreBuilder = Callable[[ServerLookup], tuple[OAuthTokenStore, bool]] +StoreBuilder = Callable[[ServerLookup], tuple[InvalidatableOAuthTokenStore, bool]] async def _read_credential(user_id: str, server_id: str) -> dict[str, object] | None: @@ -185,7 +185,7 @@ class LazyPerUserOAuthTokenStore: self._server_lookup = server_lookup self._store_builder = store_builder self._redis_available = redis_available - self._store: OAuthTokenStore | None = None + self._store: InvalidatableOAuthTokenStore | None = None self._uses_redis = False self._fetch_lock = asyncio.Condition() self._local_fetches = 0 @@ -203,7 +203,26 @@ class LazyPerUserOAuthTokenStore: if not uses_redis: await self._finish_local_fetch() - async def _store_for_fetch(self) -> tuple[OAuthTokenStore, bool]: + async def invalidate(self, user_id: str, server_id: str) -> None: + """Drop the chain's cached entry for ``(user_id, server_id)`` after the credential row + changes (re-auth, revoke). Builds the chain if no fetch has run yet, so a shared (Redis) + cache entry written by another worker is dropped too; the in-process case is then a no-op + on an empty cache. + """ + if self._uses_redis: + store = self._store + if store is not None: + await store.invalidate(user_id, server_id) + return + + store, uses_redis = await self._store_for_fetch() + try: + await store.invalidate(user_id, server_id) + finally: + if not uses_redis: + await self._finish_local_fetch() + + async def _store_for_fetch(self) -> tuple[InvalidatableOAuthTokenStore, bool]: async with self._fetch_lock: while ( self._store is not None and not self._uses_redis and self._redis_available() and self._local_fetches > 0 diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py index f9a9fa00b23..ecfd471190c 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py @@ -7,9 +7,11 @@ no precedence cascade. It is wildcard-free with an `assert_never` tail, so addin an arm fails the type gate (basedpyright `reportMatchNotExhaustive`); a bypassed gate fails loudly at runtime instead of returning `None`. -`none` and `api_key` (shared-key source) are live, as is `authorization_code`, which reads the -user's token from the injected `OAuthTokenStore`. The remaining arms are `not_implemented` stubs -that each land in a follow-up PR with their seam. Pure v2: no imports from v1. +`none`, `api_key` (shared-key source), and `passthrough` (forwards the caller's own inbound token) +are live, as is `authorization_code`, which reads the user's token from the injected +`OAuthTokenStore`, and `token_exchange`, which swaps the caller's inbound token through the +injected `TokenExchanger`. The remaining arms are `not_implemented` stubs that each land in a +follow-up PR with their seam. Pure v2: no imports from v1. """ from __future__ import annotations @@ -31,6 +33,9 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( Ok, Result, ) +from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchanger import ( + TokenExchanger, +) from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( ApiKeyConfig, AuthorizationCodeConfig, @@ -55,16 +60,36 @@ class _NullOAuthTokenStore: return None +class _NullTokenExchanger: + """Fail-closed default: with no exchanger wired, token_exchange cannot produce a credential.""" + + async def exchange( + self, subject_token: str, server: ServerSpec, config: TokenExchangeConfig, *, tenant_id: str = "" + ) -> Result[OAuthToken, CredError]: + return Error(CredError.of_misconfigured("token exchange collaborator not wired")) + + async def invalidate( + self, subject_token: str, server: ServerSpec, config: TokenExchangeConfig, *, tenant_id: str = "" + ) -> None: + return None + + class UpstreamCredentialProvider: """Produces the one `httpx.Auth` for a `(subject, upstream)` pair, per declared mode. Collaborators (the per-mode credential stores and token fetchers) are injected as each arm is built; the live `none` and `api_key`-shared arms read from the config and need none, while - `authorization_code` reads the user's token from the injected `OAuthTokenStore`. + `authorization_code` reads the user's token from the injected `OAuthTokenStore` and + `token_exchange` swaps the caller's token through the injected `TokenExchanger`. """ - def __init__(self, oauth_token_store: OAuthTokenStore | None = None) -> None: + def __init__( + self, + oauth_token_store: OAuthTokenStore | None = None, + token_exchanger: TokenExchanger | None = None, + ) -> None: self._oauth_token_store: OAuthTokenStore = oauth_token_store or _NullOAuthTokenStore() + self._token_exchanger: TokenExchanger = token_exchanger or _NullTokenExchanger() async def resolve_credentials(self, subject: Subject, server: ServerSpec) -> Result[httpx.Auth, CredError]: match server.config: @@ -73,11 +98,11 @@ class UpstreamCredentialProvider: case ApiKeyConfig() as config: return self._api_key(config) case PassthroughConfig(): - return _not_implemented(AuthSpecKind.passthrough) + return self._passthrough(subject) case ClientCredentialsConfig(): return _not_implemented(AuthSpecKind.client_credentials) - case TokenExchangeConfig(): - return _not_implemented(AuthSpecKind.token_exchange) + case TokenExchangeConfig() as config: + return await self._token_exchange(subject, server, config) case AuthorizationCodeConfig(): return await self._authorization_code(subject, server) case AwsSigV4Config(): @@ -94,6 +119,18 @@ class UpstreamCredentialProvider: """ return await self._authz_token(subject, server) is not None + def _passthrough(self, subject: Subject) -> Result[httpx.Auth, CredError]: + """Forward the caller's own upstream credential verbatim; the gateway mints nothing. + + The inbound token is the caller's already-disambiguated ``Authorization`` (never the LiteLLM + admission credential; the edge adapter drops that before building the ``Subject``). When it is + absent the request is sent unauthenticated so the upstream's own 401 surfaces, rather than the + gateway challenging on the upstream's behalf. + """ + if subject.inbound_token is None: + return Ok(NoOpAuth()) + return Ok(StaticHeaderAuth(subject.inbound_token.get_secret_value(), header_name="Authorization")) + def _api_key(self, config: ApiKeyConfig) -> Result[httpx.Auth, CredError]: match config.key_source: case SharedKey() as source: @@ -110,6 +147,43 @@ class UpstreamCredentialProvider: return Error(CredError.of_unauthorized("Authorization required: complete the OAuth flow for this server.")) return Ok(StaticHeaderAuth(f"Bearer {token.access_token}", header_name="Authorization")) + async def _token_exchange( + self, subject: Subject, server: ServerSpec, config: TokenExchangeConfig + ) -> Result[StaticHeaderAuth, CredError]: + """RFC 8693 OBO: exchange the caller's inbound token for an upstream-bound bearer. + + No inbound token means there is nothing to exchange, so the arm fails closed with a 401 rather + than falling through to a weaker source (§1.5); the exchanger handles the IdP round-trip and + caching and returns the upstream token or a typed error. + """ + inbound = subject.inbound_token + if inbound is None: + return Error( + CredError.of_unauthorized( + "Token exchange requires a caller token to exchange (OBO).", + www_authenticate='Bearer error="invalid_request"', + ) + ) + match await self._token_exchanger.exchange( + inbound.get_secret_value(), server, config, tenant_id=subject.tenant_id + ): + case Ok(token): + return Ok(StaticHeaderAuth(f"Bearer {token.access_token}", header_name="Authorization")) + case Error(err): + return Error(err) + + async def invalidate_credentials(self, subject: Subject, server: ServerSpec) -> None: + """Drop any cached credential the resolver owns for this `(subject, server)`. + + Used after an upstream rejects the injected credential, so the next resolve re-mints rather + than serving the same rejected token until TTL. Only `token_exchange` holds a re-mintable + cached credential here; other modes are a no-op. + """ + if isinstance(server.config, TokenExchangeConfig) and subject.inbound_token is not None: + await self._token_exchanger.invalidate( + subject.inbound_token.get_secret_value(), server, server.config, tenant_id=subject.tenant_id + ) + async def _authz_token(self, subject: Subject, server: ServerSpec) -> OAuthToken | None: """The user's authorization_code token, or None when absent or the store is unreachable. diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchange_provider.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchange_provider.py new file mode 100644 index 00000000000..e49de4559c6 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchange_provider.py @@ -0,0 +1,114 @@ +"""Composition root for the v2-native token_exchange (OBO) exchanger. + +Wires the pure ``OboTokenExchanger`` to its runtime edges: the real httpx POST against the IdP and +the configured cache sizing/TTL constants. ``build_token_exchanger`` is built once at egress +construction and reused, so the in-process exchanged-token cache survives across requests. Unlike the +per-user store, nothing here reads a runtime global at build time (the httpx client is acquired per +call), so it needs no lazy wrapper. +""" + +from __future__ import annotations + +import httpx + +from litellm._logging import verbose_logger +from litellm.constants import ( + MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL, + MCP_OAUTH2_TOKEN_CACHE_MIN_TTL, + MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS, + MCP_TOKEN_EXCHANGE_CACHE_MAX_SIZE, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import ( + InMemoryTokenCacheBackend, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchanger import ( + OboTokenExchanger, + SubjectTokenRejected, + TokenExchangeClientError, +) + +# RFC 6749 5.2 error codes that mean the gateway's own request/credentials are wrong (not the +# caller's subject token), so they surface as a 500 the caller can't fix by re-authenticating. +_GATEWAY_FAULT_OAUTH_ERRORS = frozenset( + {"invalid_client", "unauthorized_client", "unsupported_grant_type", "invalid_target", "invalid_scope"} +) + + +def _oauth_error_fields(response: httpx.Response) -> tuple[str | None, str | None]: + """Read the RFC 6749 5.2 ``error`` code and the IdP's step-up ``claims`` blob from a + token-endpoint error body, as ``(error, claims)`` with None for whatever is absent. + + ``claims`` is the Entra Conditional Access / CAE challenge (a JSON string the client must + replay to the IdP to satisfy the step-up); it is the caller's own requirement, not an IdP + internal, so it may travel to the caller. The ``error_description`` is deliberately not read: + it can carry IdP internals and must never reach the caller. + """ + try: + body: object = response.json() + except Exception: # noqa: BLE001 + return None, None + if not isinstance(body, dict): + return None, None + code = body.get("error") + claims = body.get("claims") + return ( + code if isinstance(code, str) else None, + claims if isinstance(claims, str) and claims else None, + ) + + +async def _post_exchange_endpoint( + url: str, form: dict[str, str], client_auth_headers: dict[str, str] +) -> dict[str, object] | None: + from litellm.llms.custom_httpx.http_handler import ( # noqa: PLC0415 + get_async_httpx_client, # pyright: ignore + ) + from litellm.types.llms.custom_http import httpxSpecialProvider # noqa: PLC0415 + + # litellm's httpx handler and httpx.Response are only partially typed; the IdP returns a JSON + # object and the exchanger validates each field, so the untyped boundary is contained here. + # A 4xx is the IdP rejecting the subject (non-retryable -> 401 via SubjectTokenRejected); any + # other failure is a miss (-> None -> upstream_unavailable -> 503), matching v1's fail-closed. + headers = {"Accept": "application/json", **client_auth_headers} + try: + client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) # pyright: ignore + response = await client.post(url, headers=headers, data=form) # pyright: ignore + response.raise_for_status() # pyright: ignore + parsed: object = response.json() # pyright: ignore + except httpx.HTTPStatusError as status_err: + status_code = status_err.response.status_code + if 400 <= status_code < 500: + oauth_error, claims = _oauth_error_fields(status_err.response) + if oauth_error in _GATEWAY_FAULT_OAUTH_ERRORS: + verbose_logger.warning( + "MCP token exchange rejected as %s (HTTP %d); check the gateway client credentials, " + "audience, and scope for this server", + oauth_error, + status_code, + ) + raise TokenExchangeClientError(oauth_error) from status_err + raise SubjectTokenRejected( + f"IdP rejected the subject token (HTTP {status_code})", + claims=claims, + ) from status_err + verbose_logger.warning("MCP token exchange request failed: %s", status_err) + return None + except Exception as exc: # noqa: BLE001 + verbose_logger.warning("MCP token exchange request failed: %s", exc) + return None + if not isinstance(parsed, dict): + # A valid-but-non-object JSON body (list/string/number) would crash the field parsing; map it + # to a miss so it surfaces as a typed upstream_unavailable, not a 500. + verbose_logger.warning("MCP token exchange returned non-object JSON (%s)", type(parsed).__name__) + return None + return parsed # pyright: ignore + + +def build_token_exchanger() -> OboTokenExchanger: + return OboTokenExchanger( + _post_exchange_endpoint, + cache=InMemoryTokenCacheBackend(max_size=MCP_TOKEN_EXCHANGE_CACHE_MAX_SIZE), + default_ttl_seconds=MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL, + min_ttl_seconds=MCP_OAUTH2_TOKEN_CACHE_MIN_TTL, + expiry_buffer_seconds=MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS, + ) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchanger.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchanger.py new file mode 100644 index 00000000000..02b6d4eafb1 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchanger.py @@ -0,0 +1,376 @@ +"""v2-native OBO token exchange: swap the caller's token for an upstream-bound one. + +The pure core of the ``token_exchange`` mode. Given the caller's inbound token and the server's +``TokenExchangeConfig``, ``OboTokenExchanger.exchange`` POSTs the grant selected by ``config.profile`` +to the configured endpoint and returns the upstream-bound ``access_token`` as a typed ``OAuthToken``, +or a typed ``CredError`` - never a raise (the HTTP edge is the injected ``ExchangeHttpPost``, whose +adapter contains the I/O). Two profiles share this one engine: ``rfc8693`` (the RFC 8693 token-exchange +grant) and ``entra_obo`` (Microsoft Entra On-Behalf-Of, which is the RFC 7523 ``jwt-bearer`` grant); +only the request form differs, so the cache, single-flight, and TTL machinery are dialect-agnostic. The +exchanged token is cached and single-flighted per ``(subject_token, tenant, config, server)`` so a +repeated caller token skips the IdP round-trip and concurrent calls collapse to one exchange, reusing +the shared in-process cache + coordinator foundation. A rotated caller token hashes to a new key and +re-exchanges. Pure v2 apart from the shared RFC 6749 client-auth helper, which carries no v1 state. + +A missing/expired exchange is an error, never a fall-through to a weaker source (§1.5): the caller +presenting no token is the resolver arm's 401, and an IdP that does not return a usable token is an +``upstream_unavailable`` here. +""" + +from __future__ import annotations + +import hashlib +import time +from collections.abc import Awaitable, Callable +from typing import Literal, Protocol + +from typing_extensions import assert_never + +from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import ( + InMemoryTokenCacheBackend, + InProcessRefreshCoordinator, + OAuthToken, + RefreshCoordinator, + TokenCacheBackend, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( + Error, + Ok, + Result, +) +from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import ( + build_token_endpoint_client_auth, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( + CredError, + ServerSpec, + TokenExchangeConfig, +) + +# A token with no declared expiry is cached for this long; one with an expiry is cached until then +# minus the skew buffer, floored at the minimum. Values mirror v1's MCP_OAUTH2_* constants; the +# composition root injects the configured ones. +_DEFAULT_TTL_SECONDS = 3600.0 +_MIN_TTL_SECONDS = 10.0 +_EXPIRY_BUFFER_SECONDS = 60.0 + +_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" +# Microsoft Entra On-Behalf-Of speaks the RFC 7523 jwt-bearer grant, not RFC 8693, and gates delegation +# behind ``requested_token_use=on_behalf_of`` (a Microsoft extension present in neither RFC). +_JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" +_REQUESTED_TOKEN_USE_OBO = "on_behalf_of" + +# RFC 8693 3 token-type URNs that are not usable as an upstream Bearer access token. token_type +# already rejects the common non-access case (N_A); this catches a malformed STS that mints one of +# these but still labels it Bearer. An access_token / jwt / absent / unknown type is accepted (lenient). +_NON_ACCESS_ISSUED_TOKEN_TYPES = frozenset( + { + "urn:ietf:params:oauth:token-type:refresh_token", + "urn:ietf:params:oauth:token-type:id_token", + "urn:ietf:params:oauth:token-type:saml1", + "urn:ietf:params:oauth:token-type:saml2", + } +) + +# The IdP returns an opaque JSON object; the post adapter hands it over untyped and the exchanger +# validates each field, so no Any leaks past this seam (None == any transport/HTTP failure). The +# second dict is the form body; the third is the client-auth headers (HTTP Basic for +# client_secret_basic, empty for client_secret_post). +ExchangeHttpPost = Callable[[str, "dict[str, str]", "dict[str, str]"], Awaitable["dict[str, object] | None"]] + + +class SubjectTokenRejected(Exception): + """The IdP refused to exchange the subject token (an RFC 8693 4xx, e.g. ``invalid_grant``). + + Distinct from a transport / IdP-availability failure, which the post adapter maps to ``None`` -> + ``upstream_unavailable`` -> 503 (retryable). A rejected subject is the caller's problem, not the + gateway's, so the arm surfaces it as a non-retryable 401 (the OBO challenge) instead. + ``claims`` is the IdP's step-up challenge blob (Entra Conditional Access / CAE) from the + rejection body; it threads into the 401 challenge so the client can satisfy the step-up and + retry. The ``error_description`` is never carried (it can leak IdP internals). + """ + + def __init__(self, detail: str, *, claims: str | None = None) -> None: + super().__init__(detail) + self.claims = claims + + +class TokenExchangeClientError(Exception): + """The IdP rejected the exchange for a reason that is the gateway's fault, not the caller's. + + RFC 6749 5.2 codes such as ``invalid_client`` (the gateway's own STS credentials are wrong), + ``unauthorized_client`` / ``unsupported_grant_type`` (the gateway is not permitted to exchange), + ``invalid_target`` / ``invalid_scope`` (the gateway's audience/scope config for this server is + wrong). The caller cannot fix these by re-authenticating, so the arm surfaces them as a 500 + (``misconfigured``), not the 401 OBO challenge. The IdP ``error_description`` is never carried. + """ + + +class TokenExchanger(Protocol): + """Exchanges a caller token for an upstream-bound one, per the server's token_exchange config.""" + + async def exchange( + self, subject_token: str, server: ServerSpec, config: TokenExchangeConfig, *, tenant_id: str = "" + ) -> Result[OAuthToken, CredError]: ... + + async def invalidate( + self, subject_token: str, server: ServerSpec, config: TokenExchangeConfig, *, tenant_id: str = "" + ) -> None: ... + + +def _cache_key(subject_token: str, tenant_id: str, config: TokenExchangeConfig) -> str: + """Bind the cache entry to the caller token, the tenant, AND the exchange config that minted it. + + A rotated caller token, a different tenant, profile, endpoint, audience, scope, client_id, secret, + auth method, or subject_token_type all change the key, so two tenants behind the same opaque token + never share an entry and a config change (including a profile flip that alters the wire form) + forces a fresh exchange instead of serving a token minted for the old config until TTL. Everything + is hashed, so no secret is held in the key. + """ + secret = config.client_secret.get_secret_value() if config.client_secret else "" + material = "\x00".join( + ( + subject_token, + tenant_id, + config.profile, + config.token_exchange_endpoint or "", + config.audience or "", + config.subject_token_type, + config.client_id or "", + secret, + config.token_endpoint_auth_method or "", + " ".join(config.scopes), + ) + ) + return hashlib.sha256(material.encode()).hexdigest() + + +def _parse_expires_in(raw: object) -> int | None: + if isinstance(raw, bool): + return None + if isinstance(raw, (int, float)): + return int(raw) + if isinstance(raw, str): + try: + return int(float(raw)) + except ValueError: + return None + return None + + +def _rfc8693_form( + *, + subject_token: str, + subject_token_type: str, + audience: str | None, + scopes: tuple[str, ...], +) -> dict[str, str]: + return { + "grant_type": _GRANT_TYPE, + "subject_token": subject_token, + "subject_token_type": subject_token_type, + **({"audience": audience} if audience else {}), + **({"scope": " ".join(scopes)} if scopes else {}), + } + + +def _entra_obo_form( + *, + subject_token: str, + scopes: tuple[str, ...], +) -> dict[str, str]: + # Microsoft Entra On-Behalf-Of (RFC 7523 jwt-bearer, not RFC 8693): the caller's inbound access + # token rides as ``assertion`` (its ``aud`` must be this gateway's ``client_id``); the target + # resource is carried in ``scope`` (e.g. api:///.default), since Entra has no audience + # parameter and ignores subject_token_type; ``requested_token_use=on_behalf_of`` is the Microsoft + # extension that turns the jwt-bearer grant into a delegation. ``scope`` is required, and the + # exchange precondition rejects an empty one, so it is always present here. Client authentication + # (client_id/client_secret via post, or Basic) is layered on by the caller through + # build_token_endpoint_client_auth, so it is not built into the form here. + return { + "grant_type": _JWT_BEARER_GRANT_TYPE, + "assertion": subject_token, + "scope": " ".join(scopes), + "requested_token_use": _REQUESTED_TOKEN_USE_OBO, + } + + +def _build_exchange_form( + *, + profile: Literal["rfc8693", "entra_obo"], + subject_token: str, + subject_token_type: str, + audience: str | None, + scopes: tuple[str, ...], +) -> dict[str, str]: + match profile: + case "rfc8693": + return _rfc8693_form( + subject_token=subject_token, + subject_token_type=subject_token_type, + audience=audience, + scopes=scopes, + ) + case "entra_obo": + return _entra_obo_form( + subject_token=subject_token, + scopes=scopes, + ) + assert_never(profile) + + +class OboTokenExchanger: + """``TokenExchanger`` that runs the profile's OBO grant once per caller token, then caches the result. + + The HTTP post is injected (``None`` on any IdP failure, mirroring v1: a failed exchange is a miss, + not a 500). The cache and single-flight coordinator default to the in-process foundation; a + deployment with no shared state needs nothing more (v1's exchanged-token cache is per-process too). + The clock is injected so TTL/expiry is deterministic in tests. + """ + + def __init__( + self, + http_post: ExchangeHttpPost, + *, + cache: TokenCacheBackend | None = None, + coordinator: RefreshCoordinator | None = None, + clock: Callable[[], float] = time.time, + default_ttl_seconds: float = _DEFAULT_TTL_SECONDS, + min_ttl_seconds: float = _MIN_TTL_SECONDS, + expiry_buffer_seconds: float = _EXPIRY_BUFFER_SECONDS, + ) -> None: + self._http_post = http_post + self._cache: TokenCacheBackend = cache or InMemoryTokenCacheBackend(clock=clock) + self._coordinator: RefreshCoordinator = coordinator or InProcessRefreshCoordinator() + self._clock = clock + self._default_ttl_seconds = default_ttl_seconds + self._min_ttl_seconds = min_ttl_seconds + self._expiry_buffer_seconds = expiry_buffer_seconds + + async def exchange( + self, subject_token: str, server: ServerSpec, config: TokenExchangeConfig, *, tenant_id: str = "" + ) -> Result[OAuthToken, CredError]: + endpoint = config.token_exchange_endpoint + client_id = config.client_id + client_secret = config.client_secret + if not endpoint: + # No endpoint configured and none discoverable: fail closed (412) rather than guess an IdP + # or fall back to a weaker source. The caller's token is never sent anywhere. + return Error( + CredError.of_precondition_required("token exchange endpoint is not configured for this server") + ) + if not client_id or client_secret is None: + return Error(CredError.of_misconfigured("token_exchange requires client_id and client_secret")) + if config.profile == "entra_obo" and not config.scopes: + # Entra carries the target resource in ``scope`` (api:///.default); with no scope the + # IdP cannot resolve an audience, so fail closed as misconfigured rather than POST a form the + # IdP will reject. + return Error( + CredError.of_misconfigured("entra_obo token exchange requires a scope (e.g. api:///.default)") + ) + + cache_key = _cache_key(subject_token, tenant_id, config) + server_id = server.server_id + cached = await self._cache.get(cache_key, server_id) + if cached is not None: + verbose_logger.debug("MCP token exchange cache hit for server %s", server_id) + return Ok(cached) + + client_auth = build_token_endpoint_client_auth( + auth_method=config.token_endpoint_auth_method, + client_id=client_id, + client_secret=client_secret.get_secret_value(), + ) + form = { + **_build_exchange_form( + profile=config.profile, + subject_token=subject_token, + subject_token_type=config.subject_token_type, + audience=config.audience, + scopes=config.scopes, + ), + **client_auth.body, + } + + async def run_exchange() -> OAuthToken | None: + fresh = await self._cache.get(cache_key, server_id) + if fresh is not None: + return fresh + verbose_logger.debug( + "Exchanging token for MCP server %s at %s (audience=%s)", server_id, endpoint, config.audience + ) + body = await self._http_post(endpoint, form, client_auth.headers) + if body is None: + return None + token = self._token_from_body(body) + if token is None: + return None + await self._cache.set(cache_key, server_id, token, self._ttl_seconds(token)) + verbose_logger.info("Token exchange succeeded for MCP server %s", server_id) + return token + + async def reread() -> OAuthToken | None: + return await self._cache.get(cache_key, server_id) + + try: + token = await self._coordinator.run(cache_key, server_id, refresh=run_exchange, reread=reread) + except SubjectTokenRejected as rejected: + # The IdP rejected the subject token (4xx). This is non-retryable: the caller must + # re-authenticate with the IdP, so it surfaces as a 401 (the OBO challenge), not a 503. + # A step-up rejection (Entra Conditional Access) carries the claims blob through so the + # edge's challenge tells the client how to satisfy it. + return Error( + CredError.of_unauthorized( + str(rejected) or "subject token rejected by the IdP", + claims=rejected.claims, + ) + ) + except TokenExchangeClientError: + # RFC 6749 5.2 gateway-fault code (invalid_client / invalid_target / ...): the caller can't + # fix it by re-authenticating, so surface a 500 rather than the OBO 401 challenge. The + # specific code is logged at the edge; the user-facing summary stays generic. + return Error( + CredError.of_misconfigured( + "token exchange configuration error: the gateway's credentials, audience, or scope " + "for this server were not accepted by the IdP" + ) + ) + if token is None: + return Error(CredError.of_upstream_unavailable("token exchange did not return a usable access token")) + return Ok(token) + + async def invalidate( + self, subject_token: str, server: ServerSpec, config: TokenExchangeConfig, *, tenant_id: str = "" + ) -> None: + """Drop the cached exchanged token so the next call re-exchanges (e.g. after an upstream 401).""" + await self._cache.delete(_cache_key(subject_token, tenant_id, config), server.server_id) + + def _token_from_body(self, body: dict[str, object]) -> OAuthToken | None: + access_token = body.get("access_token") + if not isinstance(access_token, str) or not access_token: + return None + # token_type is forwarded downstream as Bearer, so a present-but-non-Bearer type (e.g. N_A) + # must fail closed rather than be minted as a bogus Bearer; an absent type defaults to Bearer. + token_type = body.get("token_type") + if isinstance(token_type, str) and token_type.strip().lower() != "bearer": + verbose_logger.warning( + "MCP token exchange returned unusable token_type %r; refusing to forward it as Bearer", token_type + ) + return None + # issued_token_type says what representation was minted; reject a clearly-non-access type + # (refresh/id/saml) even if token_type claimed Bearer. access_token / jwt / absent / unknown pass. + issued_token_type = body.get("issued_token_type") + if isinstance(issued_token_type, str) and issued_token_type in _NON_ACCESS_ISSUED_TOKEN_TYPES: + return None + expires_in = _parse_expires_in(body.get("expires_in")) + expires_at = self._clock() + expires_in if expires_in is not None else None + return OAuthToken(access_token=access_token, expires_at=expires_at) + + def _ttl_seconds(self, token: OAuthToken) -> float: + if token.expires_at is None: + return self._default_ttl_seconds + lifetime = max(0.0, token.expires_at - self._clock()) + # Floor at min_ttl, but never cache past the token's own expiry: a token whose remaining + # lifetime is below the buffer (or even below min_ttl) must not be served stale upstream. + return min(max(lifetime - self._expiry_buffer_seconds, self._min_ttl_seconds), lifetime) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py index 671de63eabe..7e04be4f045 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py @@ -39,6 +39,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( Ok, Result, ) +from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE class AuthSpecKind(str, Enum): @@ -67,11 +68,15 @@ class Unauthorized: ``detail`` is the human message; ``www_authenticate`` and ``body`` carry a scheme-specific challenge (e.g. BYOK's provisioning prompt) so the edge can reproduce it verbatim. + ``claims`` carries an IdP step-up challenge (e.g. Entra Conditional Access) so the edge can + fold it into the ``WWW-Authenticate`` it builds; the client replays the claims to the IdP to + satisfy the step-up, then retries with the fresh token. """ detail: str www_authenticate: str | None = None body: Mapping[str, str] | None = None + claims: str | None = None @tagged_union(frozen=True) @@ -104,8 +109,16 @@ class CredError: *, www_authenticate: str | None = None, body: Mapping[str, str] | None = None, + claims: str | None = None, ) -> CredError: - return CredError(unauthorized=Unauthorized(detail=detail, www_authenticate=www_authenticate, body=body)) + return CredError( + unauthorized=Unauthorized( + detail=detail, + www_authenticate=www_authenticate, + body=body, + claims=claims, + ) + ) @staticmethod def of_misconfigured(detail: str) -> CredError: @@ -182,18 +195,33 @@ class ClientCredentialsConfig(BaseModel): class TokenExchangeConfig(BaseModel): - """RFC 8693 OBO; swap the caller's live subject_token for a token bound to the upstream's - audience (`server.resource`, RFC 8707). The gateway authenticates to the exchange endpoint - as an OAuth client (`client_id`/`client_secret`); the inbound token is sent only to that - endpoint, never to the upstream. + """OBO: swap the caller's live inbound token for a token bound to the upstream's audience. The + gateway authenticates to the exchange endpoint as an OAuth client (`client_id`/`client_secret`); + the inbound token is sent only to that endpoint, never to the upstream. + + `profile` selects the wire dialect, since not every IdP speaks RFC 8693: + - `rfc8693` (default) is the standard token-exchange grant: the inbound token is the + `subject_token` (typed by `subject_token_type`), the target is the optional `audience`. + - `entra_obo` is Microsoft Entra On-Behalf-Of, which is the RFC 7523 `jwt-bearer` grant rather + than 8693: the inbound token rides as `assertion`, the target resource is carried in `scopes` + (`api:///.default`, since Entra has no audience parameter), and the Microsoft-only + `requested_token_use=on_behalf_of` extension makes the jwt-bearer grant a delegation. + `subject_token_type` and `audience` are unused in this profile. + + `audience` (rfc8693 only) is optional and sent only when the operator configured one, since both + `audience` and `resource` are optional in RFC 8693 and the authorization server applies its own + default when neither is sent (fabricating one risks `invalid_target`). """ model_config = ConfigDict(frozen=True) kind: Literal[AuthSpecKind.token_exchange] = AuthSpecKind.token_exchange - subject_token_type: str = "urn:ietf:params:oauth:token-type:access_token" + profile: Literal["rfc8693", "entra_obo"] = "rfc8693" + subject_token_type: str = DEFAULT_SUBJECT_TOKEN_TYPE token_exchange_endpoint: str | None = None + audience: str | None = None client_id: str | None = None client_secret: SecretStr | None = None + token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] | None = None scopes: tuple[str, ...] = () diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index a6067a60105..111fde86ea0 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -8,6 +8,7 @@ from typing import ( Dict, List, Literal, + Mapping, Optional, Set, Tuple, @@ -68,6 +69,7 @@ if MCP_AVAILABLE: from mcp.types import Tool as MCPTool from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, global_mcp_server_manager, ) from litellm.proxy._experimental.mcp_server.oauth_utils import ( @@ -77,7 +79,8 @@ if MCP_AVAILABLE: ListMCPToolsRestAPIResponseObject, MCPInfo, MCPServer, - _fire_mcp_success_logging, + _apply_toolset_scope, + _fire_mcp_tool_call_logging, _tool_name_matches, execute_mcp_tool, filter_tools_by_allowed_tools, @@ -85,23 +88,119 @@ if MCP_AVAILABLE: ######################################################## ############ MCP Server REST API Routes ################# - async def _safe_fire_mcp_success_logging( + async def _safe_fire_mcp_tool_call_logging( logging_obj: Optional[Any], result: Any, start_time: datetime, end_time: datetime, + user_api_key_auth: Optional[UserAPIKeyAuth] = None, + request_data: Optional[Mapping[str, object]] = None, ) -> None: if logging_obj is None: return logging_results = await asyncio.gather( - _fire_mcp_success_logging(logging_obj, result, start_time, end_time), + _fire_mcp_tool_call_logging( + logging_obj, + result, + start_time, + end_time, + user_api_key_auth=user_api_key_auth, + request_data=request_data, + ), return_exceptions=True, ) logging_error = logging_results[0] if isinstance(logging_error, asyncio.CancelledError): raise logging_error if isinstance(logging_error, BaseException): - verbose_logger.warning("MCP tool success logging failed (continuing): %s", logging_error) + verbose_logger.warning("MCP tool call logging failed (continuing): %s", logging_error) + + def _relay_upstream_auth_http_exception(e: MCPUpstreamAuthError, request: Request) -> HTTPException: + """Convert a client-forwarded pass-through upstream 401 into an HTTPException that preserves the + upstream WWW-Authenticate, so a standards-compliant MCP client can run the upstream OAuth flow + instead of the generic 500 the endpoint catch-all would return.""" + return e.to_http_exception( + base_url=get_request_base_url(request), + request_path=request.scope.get("_original_path") or request.url.path, + ) + + async def _handle_virtual_mcp_tool( + request: Request, + data: Dict[str, Any], + tool_name: str, + user_api_key_dict: UserAPIKeyAuth, + ) -> Any: + """Handle the virtual ``mcp_tool_search`` / ``mcp_tool_call`` REST tools (gated on + ``mcp_tool_search_enabled``). Kept out of ``call_tool_rest_api`` so that endpoint stays a single + dispatch. An upstream 401 raised by the virtual ``mcp_tool_call`` propagates unhandled to the + caller's ``except MCPUpstreamAuthError`` relay, the same as the direct call path.""" + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCPRequestHandler, + ) + from litellm.proxy._experimental.mcp_server.tool_search import ( + MCP_TOOL_SEARCH_TOOL_NAME, + coerce_top_k, + handle_mcp_tool_call, + handle_mcp_tool_search, + ) + from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing + from litellm.proxy.proxy_server import general_settings, proxy_config, proxy_logging_obj + + if not getattr(getattr(user_api_key_dict, "object_permission", None), "mcp_tool_search_enabled", False): + raise HTTPException( + status_code=403, + detail={"error": "forbidden", "message": f"{tool_name} requires mcp_tool_search_enabled on the key"}, + ) + tool_arguments = data.get("arguments") or {} + rest_client_ip = IPAddressUtils.get_mcp_client_ip(request) + ( + virtual_mcp_auth_header, + virtual_mcp_server_auth_headers, + virtual_raw_headers, + ) = _extract_mcp_headers_from_request(request, MCPRequestHandler) + virtual_oauth2_headers = MCPRequestHandler._get_oauth2_headers_from_headers(request.headers) + if tool_name == MCP_TOOL_SEARCH_TOOL_NAME: + return await handle_mcp_tool_search( + query=tool_arguments.get("query", ""), + top_k=coerce_top_k(tool_arguments.get("top_k", 5)), + user_api_key_dict=user_api_key_dict, + client_ip=rest_client_ip, + mcp_auth_header=virtual_mcp_auth_header, + mcp_server_auth_headers=virtual_mcp_server_auth_headers, + oauth2_headers=virtual_oauth2_headers, + raw_headers=virtual_raw_headers, + ) + # MCP_TOOL_CALL_TOOL_NAME: run the same pre-call pipeline as the normal path so the tool + # execution is spend-logged and guardrail-checked. + (_, virtual_logging_obj) = await ProxyBaseLLMRequestProcessing(data=data).common_processing_pre_call_logic( + request=request, + user_api_key_dict=user_api_key_dict, + proxy_config=proxy_config, + route_type=CallTypes.call_mcp_tool.value, + proxy_logging_obj=proxy_logging_obj, + general_settings=general_settings, + ) + _tool_start_time = datetime.now() + result = await handle_mcp_tool_call( + tool_name=tool_arguments.get("tool_name", ""), + arguments=tool_arguments.get("arguments") or {}, + user_api_key_dict=user_api_key_dict, + client_ip=rest_client_ip, + mcp_auth_header=virtual_mcp_auth_header, + mcp_server_auth_headers=virtual_mcp_server_auth_headers, + oauth2_headers=virtual_oauth2_headers, + raw_headers=virtual_raw_headers, + litellm_logging_obj=virtual_logging_obj, + ) + await _safe_fire_mcp_tool_call_logging( + virtual_logging_obj, + result, + _tool_start_time, + datetime.now(), + user_api_key_auth=user_api_key_dict, + request_data=data, + ) + return result def _get_server_auth_header( server, @@ -541,10 +640,37 @@ if MCP_AVAILABLE: "message": "Successfully retrieved tools", } + def _as_query_str(value: Any) -> Optional[str]: + """Coerce an Optional[str] Query param to str|None, dropping unresolved FastAPI defaults.""" + return value if isinstance(value, str) else None + + async def _resolve_toolset_scope( + toolset_name: Optional[str], + user_api_key_dict: UserAPIKeyAuth, + ) -> UserAPIKeyAuth: + """Resolve ``toolset_name`` to its scoped ``UserAPIKeyAuth``, or return unchanged.""" + if not toolset_name: + return user_api_key_dict + + from litellm.proxy.utils import get_prisma_client_or_throw + + prisma_client = get_prisma_client_or_throw("Database not available. Connect a database to your proxy") + toolset = await global_mcp_server_manager.get_toolset_by_name_cached(prisma_client, toolset_name) + if toolset is None: + raise HTTPException( + status_code=404, + detail=f"Toolset '{toolset_name}' not found", + ) + return await _apply_toolset_scope(user_api_key_dict, toolset.toolset_id) + @router.get("/tools/list", dependencies=[Depends(user_api_key_auth)]) async def list_tool_rest_api( request: Request, server_id: Optional[str] = Query(None, description="The server id to list tools for"), + mcp_server_name: Optional[str] = Query( + None, description="Filter tools to a single MCP server by name or alias" + ), + toolset_name: Optional[str] = Query(None, description="Filter tools to a single toolset by name"), include_disabled_tools: bool = Query( False, description=( @@ -582,16 +708,29 @@ if MCP_AVAILABLE: ) try: + mcp_server_name = _as_query_str(mcp_server_name) + toolset_name = _as_query_str(toolset_name) + # The full catalog (allowlist filter skipped) is admin-only so the # REST endpoint can't be used to enumerate deliberately-disabled tools. apply_tool_filters = not ( include_disabled_tools and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN ) - if apply_tool_filters and getattr( - getattr(user_api_key_dict, "object_permission", None), - "mcp_tool_search_enabled", - False, + user_api_key_dict = await _resolve_toolset_scope(toolset_name, user_api_key_dict) + + if server_id is None: + server_id = mcp_server_name + + if ( + apply_tool_filters + and server_id is None + and toolset_name is None + and getattr( + getattr(user_api_key_dict, "object_permission", None), + "mcp_tool_search_enabled", + False, + ) ): from litellm.proxy._experimental.mcp_server.tool_search import ( get_virtual_tool_definitions, @@ -719,6 +858,8 @@ if MCP_AVAILABLE: request_path=request.scope.get("_original_path") or request.url.path, ) except HTTPException as http_exc: + if http_exc.status_code == status.HTTP_404_NOT_FOUND: + raise # Internal access/IP 403s keep the legacy error-dict response shape # so the existing contract stays intact. verbose_logger.exception("HTTPException in list_tool_rest_api: %s", str(http_exc)) @@ -767,70 +908,10 @@ if MCP_AVAILABLE: from litellm.proxy._experimental.mcp_server.tool_search import ( MCP_TOOL_CALL_TOOL_NAME, MCP_TOOL_SEARCH_TOOL_NAME, - coerce_top_k, - handle_mcp_tool_call, - handle_mcp_tool_search, ) if tool_name in (MCP_TOOL_SEARCH_TOOL_NAME, MCP_TOOL_CALL_TOOL_NAME): - if not getattr( - getattr(user_api_key_dict, "object_permission", None), - "mcp_tool_search_enabled", - False, - ): - raise HTTPException( - status_code=403, - detail={ - "error": "forbidden", - "message": f"{tool_name} requires mcp_tool_search_enabled on the key", - }, - ) - rest_client_ip = IPAddressUtils.get_mcp_client_ip(request) - ( - virtual_mcp_auth_header, - virtual_mcp_server_auth_headers, - virtual_raw_headers, - ) = _extract_mcp_headers_from_request(request, MCPRequestHandler) - virtual_oauth2_headers = MCPRequestHandler._get_oauth2_headers_from_headers(request.headers) - if tool_name == MCP_TOOL_SEARCH_TOOL_NAME: - return await handle_mcp_tool_search( - query=tool_arguments.get("query", ""), - top_k=coerce_top_k(tool_arguments.get("top_k", 5)), - user_api_key_dict=user_api_key_dict, - client_ip=rest_client_ip, - mcp_auth_header=virtual_mcp_auth_header, - mcp_server_auth_headers=virtual_mcp_server_auth_headers, - oauth2_headers=virtual_oauth2_headers, - raw_headers=virtual_raw_headers, - ) - else: # MCP_TOOL_CALL_TOOL_NAME - # Run the same pre-call pipeline as the normal call path so the - # tool execution is spend-logged and guardrail-checked. - ( - _, - virtual_logging_obj, - ) = await ProxyBaseLLMRequestProcessing(data=data).common_processing_pre_call_logic( - request=request, - user_api_key_dict=user_api_key_dict, - proxy_config=proxy_config, - route_type=CallTypes.call_mcp_tool.value, - proxy_logging_obj=proxy_logging_obj, - general_settings=general_settings, - ) - _tool_start_time = datetime.now() - result = await handle_mcp_tool_call( - tool_name=tool_arguments.get("tool_name", ""), - arguments=tool_arguments.get("arguments") or {}, - user_api_key_dict=user_api_key_dict, - client_ip=rest_client_ip, - mcp_auth_header=virtual_mcp_auth_header, - mcp_server_auth_headers=virtual_mcp_server_auth_headers, - oauth2_headers=virtual_oauth2_headers, - raw_headers=virtual_raw_headers, - litellm_logging_obj=virtual_logging_obj, - ) - await _safe_fire_mcp_success_logging(virtual_logging_obj, result, _tool_start_time, datetime.now()) - return result + return await _handle_virtual_mcp_tool(request, data, tool_name, user_api_key_dict) # Validate required parameters early server_id = data.get("server_id") @@ -912,7 +993,14 @@ if MCP_AVAILABLE: litellm_logging_obj=data.get("litellm_logging_obj"), requested_server_id=canonical_server_id, ) - await _safe_fire_mcp_success_logging(logging_obj, result, _tool_start_time, datetime.now()) + await _safe_fire_mcp_tool_call_logging( + logging_obj, + result, + _tool_start_time, + datetime.now(), + user_api_key_auth=user_api_key_dict, + request_data=data, + ) return result except MCPMissingUserEnvVarsError as e: verbose_logger.info( @@ -952,8 +1040,16 @@ if MCP_AVAILABLE: "guardrail_name": getattr(e, "guardrail_name", None), }, ) + except MCPUpstreamAuthError as e: + # A client-forwarded pass-through upstream 401 from either the direct or the virtual call + # branch. Relay it as a 401 + WWW-Authenticate so the MCP client can re-run upstream OAuth, + # and log at info: an expected caller-must-reauth signal, not an operator-actionable error. + verbose_logger.info(f"MCP tool call relaying upstream HTTP {e.status_code}") + raise _relay_upstream_auth_http_exception(e, request) except HTTPException as e: - # Re-raise HTTPException as-is to preserve status code and detail + # Locally generated denials (tool/server permission, IP filtering, BYOK) stay at error level + # so restriction probing keeps full monitoring visibility; the relayed upstream 401 above is + # the only status demoted to info. verbose_logger.error(f"HTTPException in MCP tool call: {str(e)}") raise e except Exception as e: @@ -1079,11 +1175,6 @@ if MCP_AVAILABLE: forwarded_authorization = ( effective_oauth2_headers.get("Authorization") if effective_oauth2_headers else None ) - is_interactive_authz_code = ( - server_model.auth_type == MCPAuth.oauth2 - and forwarded_authorization is not None - and to_server_spec(server_model) is not None - ) preview_cred_provider = ( UpstreamCredentialProvider( oauth_token_store=PresentedOAuthTokenStore( @@ -1094,7 +1185,11 @@ if MCP_AVAILABLE: ) ) ) - if is_interactive_authz_code + if ( + server_model.auth_type == MCPAuth.oauth2 + and forwarded_authorization is not None + and to_server_spec(server_model) is not None + ) else None ) @@ -1255,8 +1350,13 @@ if MCP_AVAILABLE: if isinstance(credentials, dict): mcp_auth_header = credentials.get("auth_value") + # Authorization doubles as the admission fallback (LITELLM_API_KEY_HEADER_NAME_SECONDARY): + # when the primary x-litellm-api-key header is absent, the Authorization value is the + # caller's LiteLLM key, not an upstream token, and must never be forwarded upstream. oauth2_headers: Optional[Dict[str, str]] = None - if new_mcp_server_request.auth_type == MCPAuth.oauth2: + if new_mcp_server_request.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and headers.get( + MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_PRIMARY + ): oauth2_headers = MCPRequestHandler._get_oauth2_headers_from_headers(headers) async def _list_tools_operation(client): diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index f24d5715e83..e12c6cdbd56 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -7,6 +7,8 @@ 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.exceptions import ContextWindowExceededError +from litellm.litellm_core_utils.exception_mapping_utils import ExceptionCheckers from litellm.proxy._experimental.mcp_server.utils import MCP_TOOL_PREFIX_SEPARATOR if TYPE_CHECKING: @@ -15,6 +17,36 @@ if TYPE_CHECKING: from litellm.router import Router +class SemanticToolFilterContextWindowError(Exception): + """Raised when the embedding model exceeds its context window, so semantic filtering cannot run.""" + + def __init__(self, embedding_model: str, stage: str, original_error: str): + self.embedding_model = embedding_model + self.stage = stage + self.original_error = original_error + super().__init__( + f"MCP semantic tool filtering could not run: embedding model '{embedding_model}' " + f"exceeded its context window while embedding {stage}. " + f"The request was blocked instead of silently passing all tools through. " + f"Switch to an embedding model with a larger context window, or disable " + f"semantic tool filtering." + ) + + +def _is_context_window_error(error: Optional[BaseException], max_depth: int = 5) -> bool: + """Detect a context-window overflow anywhere in an exception's cause chain.""" + current = error + for _ in range(max_depth): + if current is None: + return False + if isinstance(current, ContextWindowExceededError): + return True + if ExceptionCheckers.is_error_str_context_window_exceeded(str(current)): + return True + current = current.__cause__ or current.__context__ + return False + + class SemanticMCPToolFilter: """Filters MCP tools using semantic similarity to reduce context window size.""" @@ -42,6 +74,7 @@ class SemanticMCPToolFilter: self.embedding_model = embedding_model self.router_instance = litellm_router_instance self.tool_router: Optional["SemanticRouter"] = None + self.context_window_error: Optional[str] = None self._tool_map: Dict[str, Any] = {} # MCPTool objects or OpenAI function dicts async def build_router_from_mcp_registry(self) -> None: @@ -111,6 +144,7 @@ class SemanticMCPToolFilter: return try: + self.context_window_error = None # Convert tools to routes routes = [] self._tool_map = {} @@ -143,6 +177,9 @@ class SemanticMCPToolFilter: except Exception as e: verbose_logger.error(f"Failed to build semantic router: {e}") self.tool_router = None + if _is_context_window_error(e): + self.context_window_error = str(e) + return raise async def filter_tools( @@ -169,6 +206,13 @@ class SemanticMCPToolFilter: if not available_tools: return available_tools + if self.context_window_error is not None: + raise SemanticToolFilterContextWindowError( + embedding_model=self.embedding_model, + stage="the MCP tool descriptions during semantic router build", + original_error=self.context_window_error, + ) + if not query or not query.strip(): return available_tools @@ -189,6 +233,16 @@ class SemanticMCPToolFilter: return self._get_tools_by_names(matched_tool_names, available_tools) except Exception as e: + if _is_context_window_error(e): + verbose_logger.error( + f"Semantic tool filter embedding exceeded its context window: {e}", + exc_info=True, + ) + raise SemanticToolFilterContextWindowError( + embedding_model=self.embedding_model, + stage="the user query", + original_error=str(e), + ) from e verbose_logger.error(f"Semantic tool filter failed: {e}", exc_info=True) return available_tools diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 57404793269..68a61b85175 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -20,12 +20,14 @@ from typing import ( Callable, Dict, List, + Mapping, Optional, Set, Tuple, Union, cast, ) +from urllib.parse import urlsplit, urlunsplit import httpx from fastapi import FastAPI, HTTPException @@ -47,7 +49,10 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( get_request_base_url, ) -from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError +from litellm.proxy._experimental.mcp_server.exceptions import ( + MCPToolResultError, + MCPUpstreamAuthError, +) from litellm.proxy._experimental.mcp_server.mcp_context import ( _mcp_active_toolset_id, _mcp_gateway_initialize_instructions, @@ -60,6 +65,7 @@ from litellm.proxy._experimental.mcp_server.utils import ( LITELLM_MCP_SERVER_VERSION, MCPMissingUserEnvVarsError, add_server_prefix_to_name, + extract_mcp_tool_result_error_message, get_server_prefix, iter_known_server_prefixes, ) @@ -100,6 +106,27 @@ _MAX_STATEFUL_SESSIONS_PER_OWNER = 100 _MCP_ROUTING_PEEK_MAX_BYTES = 4096 +def _redact_mcp_resource_url(url: Optional[str]) -> Optional[str]: + """Reduce an MCP server URL to its origin (scheme + host + port) for logging. + + Everything else is dropped: userinfo (``user:pass@``), the query string, the + fragment, and the path, because hosted MCP servers routinely embed the + credential in the path (e.g. ``/mcp/s/``) and this value is persisted + in spend-log metadata that a caller who can invoke the tool can read back. + Returns None when the URL has no host to identify (nothing safe to log). + """ + if not isinstance(url, str) or not url: + return None + try: + parts = urlsplit(url) + except ValueError: + return None + if not parts.hostname: + return None + netloc = f"{parts.hostname}:{parts.port}" if parts.port else parts.hostname + return urlunsplit((parts.scheme, netloc, "", "", "")) or None + + def _invalidate_byok_cred_cache(user_id: str, server_id: str) -> None: """Remove a (user_id, server_id) entry from the BYOK credential cache. @@ -331,6 +358,8 @@ if MCP_AVAILABLE: ) from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( MCPServerManager, + _caller_authorization_fans_out, + _client_forwarded_authorization_headers, _should_strip_caller_authorization, _without_authorization, global_mcp_server_manager, @@ -716,7 +745,7 @@ if MCP_AVAILABLE: if not (host_ctx and hasattr(host_ctx, "meta") and host_ctx.meta): return None host_token = getattr(host_ctx.meta, "progressToken", None) - if not (host_token and hasattr(host_ctx, "session") and host_ctx.session): + if host_token is None or not (hasattr(host_ctx, "session") and host_ctx.session): return None host_session = host_ctx.session @@ -732,7 +761,7 @@ if MCP_AVAILABLE: except Exception as e: verbose_logger.error(f"Failed to forward progress to Host: {e}") - verbose_logger.debug(f"Host progressToken captured: {host_token[:8]}...") + verbose_logger.debug(f"Host progressToken captured: {str(host_token)[:8]}...") return forward_progress async def _build_virtual_call_logging_obj( @@ -977,6 +1006,22 @@ if MCP_AVAILABLE: content=[TextContent(text=f"Error: {str(e.detail)}", type="text")], isError=True, ) + except MCPUpstreamAuthError as e: + # The MCP session manager serializes handler exceptions as JSON-RPC errors, so a + # mid-session tool call cannot emit a raw 401 + WWW-Authenticate the way the REST + # call path and the connect-time preemptive check do. Return an explicit isError + # naming the upstream status (at info level, not a traceback) so the client still + # learns it must re-authenticate upstream and expected pass-through 401s don't spam. + verbose_logger.info(f"Upstream auth failure calling MCP tool: HTTP {e.status_code}") + return CallToolResult( + content=[ + TextContent( + text=f"Error: upstream authentication required (HTTP {e.status_code})", + type="text", + ) + ], + isError=True, + ) except Exception as e: verbose_logger.exception(f"MCP mcp_server_tool_call - error: {e}") return CallToolResult( @@ -1427,18 +1472,8 @@ if MCP_AVAILABLE: for allowed_mcp_server_id in allowed_mcp_server_ids: mcp_server = global_mcp_server_manager.get_mcp_server_by_id(allowed_mcp_server_id) if mcp_server is not None: - # Apply oauth2_flow resolution for legacy DB rows where it may be NULL - resolved_flow = MCPServerManager._resolve_oauth2_flow( - auth_type=mcp_server.auth_type, - oauth2_flow=mcp_server.oauth2_flow, - token_url=mcp_server.token_url, - authorization_url=mcp_server.authorization_url, - client_id=mcp_server.client_id, - client_secret=mcp_server.client_secret, - ) - if resolved_flow and resolved_flow != mcp_server.oauth2_flow: - # Create a new instance with the resolved flow for this request - mcp_server = mcp_server.model_copy(update={"oauth2_flow": resolved_flow}) + # Apply the request-time oauth2_flow backstop for legacy null rows. + mcp_server = MCPServerManager.resolve_oauth2_flow_for_request(mcp_server) allowed_mcp_servers.append(mcp_server) if mcp_servers is not None: @@ -1449,6 +1484,35 @@ if MCP_AVAILABLE: return allowed_mcp_servers + def _client_has_per_server_auth_header( + server: MCPServer, + mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]], + ) -> bool: + """True if the request carries a per-server ``x-mcp-{alias}-authorization`` + header for this server. This is the multi-server binding: it names one + upstream, so it is unambiguously the caller's upstream token regardless of + auth mode (never the LiteLLM admission credential). + + Resolves through the same ``lookup_mcp_server_auth_in_headers`` egress uses, so + the connect gate and egress agree on which per-server header names match: a + dashboard client sends ``x-mcp-{sanitize_mcp_alias_for_header(alias)}-authorization``, + and matching only the raw alias here would 401 a token egress would forward. + """ + if not mcp_server_auth_headers: + return False + from litellm.proxy._experimental.mcp_server.utils import ( + lookup_mcp_server_auth_in_headers, + ) + + server_headers = lookup_mcp_server_auth_in_headers( + mcp_server_auth_headers, alias=server.alias, server_name=server.server_name + ) + if isinstance(server_headers, str): + return bool(server_headers.strip()) + if isinstance(server_headers, dict): + return any(isinstance(hk, str) and hk.lower() == "authorization" for hk in server_headers) + return False + def _client_has_passthrough_authorization( server: MCPServer, oauth2_headers: Optional[Dict[str, str]], @@ -1466,24 +1530,7 @@ if MCP_AVAILABLE: for k in oauth2_headers.keys(): if k.lower() == "authorization": return True - if mcp_server_auth_headers: - for key in (server.alias, server.server_name, server.name): - if not key: - continue - server_headers = None - for k, v in mcp_server_auth_headers.items(): - if k.lower() == key.lower(): - server_headers = v - break - if server_headers is None: - continue - if isinstance(server_headers, str) and server_headers.strip(): - return True - if isinstance(server_headers, dict): - for hk in server_headers.keys(): - if hk.lower() == "authorization": - return True - return False + return _client_has_per_server_auth_header(server, mcp_server_auth_headers) async def _get_user_oauth_extra_headers_from_db( server: MCPServer, @@ -1538,8 +1585,16 @@ if MCP_AVAILABLE: oauth2_headers: Optional[Dict[str, str]], raw_headers: Optional[Dict[str, str]], user_api_key_auth: Optional[UserAPIKeyAuth] = None, + scope_servers: Optional[list[MCPServer]] = None, ) -> Tuple[Optional[Union[Dict[str, str], str]], Optional[Dict[str, str]]]: - """Build auth and extra headers for a server.""" + """Build auth and extra headers for a server. + + ``scope_servers`` is the full server list a fan-out handler iterates. Passing it lets the + client-forwarded token modes withhold the caller's request-wide ``Authorization`` when + another server in the scope would also receive it (``_caller_authorization_fans_out``); + explicitly-addressed operations leave it None. Per-server ``x-mcp-{alias}-authorization`` + headers are unaffected — they bind one token to one server and are the multi-server shape. + """ server_auth_header: Optional[Union[Dict[str, str], str]] = None if mcp_server_auth_headers: from litellm.proxy._experimental.mcp_server.utils import ( @@ -1553,6 +1608,16 @@ if MCP_AVAILABLE: ) extra_headers: Optional[Dict[str, str]] = None + is_client_forwarded_mode = server.is_true_passthrough or server.is_oauth_delegate + # In a multi-server listing scope the request-wide Authorization can only carry one token, + # so it is withheld from a client-forwarded server when another server in scope also consumes + # it (RFC 9700 cross-resource replay); such scopes must bind per-server via + # x-mcp-{alias}-authorization. The decision is computed once so BOTH the forwarding branch and + # the extra_headers copy loop below honor it — otherwise a server that lists Authorization in + # extra_headers would re-copy the withheld bearer from raw_headers and replay it anyway. + withhold_forwarded_authorization = is_client_forwarded_mode and _caller_authorization_fans_out( + server, scope_servers + ) if server.auth_type == MCPAuth.oauth2: # For OAuth2 M2M servers, upstream Authorization must come from # client_credentials token fetch, never from caller headers. @@ -1571,6 +1636,14 @@ if MCP_AVAILABLE: user_api_key_auth=user_api_key_auth, ): extra_headers = _without_authorization(extra_headers) + elif is_client_forwarded_mode: + if not withhold_forwarded_authorization: + extra_headers = _client_forwarded_authorization_headers( + mcp_server=server, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) if server.extra_headers and raw_headers: if extra_headers is None: @@ -1591,7 +1664,9 @@ if MCP_AVAILABLE: for header in server.extra_headers: if not isinstance(header, str): continue - if header.lower() == "authorization" and strip_caller_authorization: + if header.lower() == "authorization" and ( + strip_caller_authorization or withhold_forwarded_authorization + ): continue header_value = normalized_raw_headers.get(header.lower()) if header_value is None: @@ -1795,6 +1870,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) # Prefer server-stored per-user OAuth when configured, so a stale @@ -1838,6 +1914,7 @@ if MCP_AVAILABLE: add_prefix=True, # Always add server prefix raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + oauth2_headers=oauth2_headers, ) filtered_tools = filter_tools_by_allowed_tools(tools, server) @@ -1860,7 +1937,8 @@ if MCP_AVAILABLE: # tools. Surfacing the upstream 401 to the client as a re-auth challenge is # intentionally not done here: raising from this list handler cannot produce a # 401 + WWW-Authenticate (the MCP session manager serializes it as a JSON-RPC - # error), so that belongs in a request-scope preemptive check, tracked separately. + # error). Single-server routes surface it via the request-scope preemptive + # check in _raise_preemptive_401_for_unauthenticated_servers instead. verbose_logger.debug(f"MCP list_tools: omitting {server.name}; it needs upstream auth") return [] except Exception as e: @@ -1979,6 +2057,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) try: @@ -2031,6 +2110,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) try: @@ -2081,6 +2161,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) try: @@ -2692,12 +2773,18 @@ if MCP_AVAILABLE: # Forward named client headers to OpenAPI tool upstream requests. # MCPServer.extra_headers lists header names to copy from raw_headers. - # OAuth2 M2M: never take Authorization from the caller (matches - # _prepare_mcp_server_headers for managed MCP). + # The strip decision is centralized in _should_strip_caller_authorization so this + # OpenAPI/local path agrees with the managed paths: M2M and the resolver-owned modes + # (token_exchange's raw subject token, authorization_code's stored token) must never + # have the caller's Authorization forwarded verbatim upstream. forwarded_headers: Optional[Dict[str, str]] = None if mcp_server and mcp_server.extra_headers and raw_headers: normalized_raw = {str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str)} - skip_caller_authorization = bool(mcp_server.has_client_credentials) + skip_caller_authorization = _should_strip_caller_authorization( + mcp_server=mcp_server, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) for header_name in mcp_server.extra_headers: if not isinstance(header_name, str): continue @@ -2745,12 +2832,40 @@ if MCP_AVAILABLE: return response - async def _fire_mcp_success_logging( + _MCP_CREDENTIAL_REQUEST_FIELDS = frozenset( + { + "raw_headers", + "mcp_auth_header", + "mcp_server_auth_headers", + "oauth2_headers", + "user_api_key_auth", + } + ) + + async def _fire_mcp_tool_call_logging( logging_obj: LiteLLMLoggingObj, result: Any, start_time: datetime, end_time: datetime, + user_api_key_auth: Optional[UserAPIKeyAuth] = None, + request_data: Optional[Mapping[str, object]] = None, ) -> None: + """Fire post-call logging for an executed MCP tool call. + + A result with ``isError=True`` is logged as a failure (``status="failure"`` + payload, so OTel marks the span ERROR) while the HTTP wire behavior stays + 200 + ``isError: true`` per the MCP spec. The error check runs after + ``async_post_mcp_tool_call_hook`` because guardrails may flip the result + to ``isError=True`` in that hook. Raised exceptions never reach here (the + ``@client`` wrapper and ``call_mcp_tool``'s except path log those), so + this cannot double-log a failure. + + ``request_data`` may carry credential-bearing fields (the REST path puts + ``raw_headers``, ``mcp_auth_header``, ``mcp_server_auth_headers``, and + ``oauth2_headers`` at the top level of its data dict), so those are + stripped before the dict is handed to ``post_call_failure_hook`` + callbacks. + """ logging_obj.post_call(original_response=result) await logging_obj.async_post_mcp_tool_call_hook( kwargs=logging_obj.model_call_details, @@ -2759,7 +2874,31 @@ if MCP_AVAILABLE: end_time=end_time, ) logging_obj.call_type = CallTypes.call_mcp_tool.value - await logging_obj.async_success_handler(result=result, start_time=start_time, end_time=end_time) + error_message = extract_mcp_tool_result_error_message(result) + if error_message is None: + await logging_obj.async_success_handler(result=result, start_time=start_time, end_time=end_time) + return + + logging_obj.has_run_logging(event_type="sync_success") + logging_obj.has_run_logging(event_type="async_success") + tool_error = MCPToolResultError(error_message) + logging_obj.failure_handler(tool_error, "", start_time, end_time) + await logging_obj.async_failure_handler(tool_error, "", start_time, end_time) + + if user_api_key_auth is None: + return + from litellm.proxy.proxy_server import proxy_logging_obj + + if proxy_logging_obj: + sanitized_request_data = { + key: value for key, value in (request_data or {}).items() if key not in _MCP_CREDENTIAL_REQUEST_FIELDS + } + await proxy_logging_obj.post_call_failure_hook( + request_data=sanitized_request_data, + original_exception=tool_error, + user_api_key_dict=user_api_key_auth, + route="/mcp/call_tool", + ) @client async def call_mcp_tool( @@ -2792,6 +2931,9 @@ if MCP_AVAILABLE: for allowed_mcp_server_id in allowed_mcp_server_ids: allowed_server = global_mcp_server_manager.get_mcp_server_by_id(allowed_mcp_server_id) if allowed_server is not None: + # Same request-time oauth2_flow backstop the listing path applies, + # so a null-flow M2M-shape row is treated as M2M on tool calls too. + allowed_server = MCPServerManager.resolve_oauth2_flow_for_request(allowed_server) allowed_mcp_servers.append(allowed_server) allowed_mcp_servers = await _get_allowed_mcp_servers_from_mcp_server_names( @@ -2817,6 +2959,14 @@ if MCP_AVAILABLE: raw_headers=raw_headers, **kwargs, ) + except MCPUpstreamAuthError: + # A client-forwarded pass-through upstream 401 is an expected caller-must-reauth signal, so + # re-raise it without post_call_failure_hook, which fires the proxy's llm_exceptions alert. + # mcp_server_tool_call then downgrades it to an informational isError result for the + # streamable client. Note: this function is @client-decorated, so the decorator's standard + # failure logging still records the event (spend log / OTel); only the extra alert sink is + # skipped here. + raise except Exception as e: traceback_str = traceback.format_exc(limit=MAXIMUM_TRACEBACK_LINES_TO_LOG) from litellm.proxy.proxy_server import proxy_logging_obj @@ -2832,7 +2982,14 @@ if MCP_AVAILABLE: raise if litellm_logging_obj: - await _fire_mcp_success_logging(litellm_logging_obj, response, start_time, datetime.now()) + await _fire_mcp_tool_call_logging( + logging_obj=litellm_logging_obj, + result=response, + start_time=start_time, + end_time=datetime.now(), + user_api_key_auth=user_api_key_auth, + request_data=kwargs, + ) return response async def mcp_get_prompt( @@ -2953,6 +3110,8 @@ if MCP_AVAILABLE: mcp_server_logo_url=mcp_info.get("logo_url"), namespaced_tool_name=namespaced_tool_name, mcp_session_id=session_id, + mcp_auth_mode=mcp_server.auth_type, + mcp_server_resource=_redact_mcp_resource_url(mcp_server.url), ) else: return StandardLoggingMCPToolCall( @@ -3466,6 +3625,36 @@ if MCP_AVAILABLE: headers={"www-authenticate": authorization_uri}, ) + # token_exchange (OBO): the caller supplied no subject token. Challenge at connect + # (transport level, where WWW-Authenticate survives) with the RFC 9728 resource_metadata + # so the client discovers the IdP, SSOs, and retries with a subject token, which LiteLLM + # then exchanges. A tool-call-time 401 would be wrapped into a JSON-RPC error and the + # header lost, so the discovery flow needs this pre-emptive challenge. + if server and server.auth_type == MCPAuth.oauth2_token_exchange and not oauth2_headers: + from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( # noqa: PLC0415 + raise_token_exchange_challenge, + ) + from litellm.proxy.utils import get_server_root_path # noqa: PLC0415 + + raise_token_exchange_challenge(server, root_path=get_server_root_path()) + + # token_exchange (OBO) with a subject present: run the exchange here at the transport + # edge, so a rejected subject raises the RFC 9728 challenge (and a gateway fault its + # public status) instead of the session opening and list_tools masking the failure as + # an empty tool list. Gated to single-server routes; the multi-server aggregate keeps + # absorbing per-server auth failures so one bad server cannot 401 the whole connect. + if ( + server + and server.auth_type == MCPAuth.oauth2_token_exchange + and oauth2_headers + and len(mcp_servers or []) == 1 + ): + await global_mcp_server_manager.preflight_token_exchange( + server=server, + oauth2_headers=oauth2_headers, + user_api_key_auth=user_api_key_auth, + ) + # Pass-through OAuth: when the admin has opted a server into # forwarding the client's bearer token (is_oauth_passthrough) and # the client hasn't supplied one, fail fast with 401 and point @@ -3487,6 +3676,59 @@ if MCP_AVAILABLE: headers={"www-authenticate": www_authenticate}, ) + if ( + server + and server.is_oauth_delegate + and len(mcp_servers or []) == 1 + and _get_forwarded_auth_from_scope(scope) is None + and not _client_has_per_server_auth_header(server, mcp_server_auth_headers) + ): + www_authenticate = _get_passthrough_www_authenticate( + scope=scope, + server_name=server_name, + ) + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"www-authenticate": www_authenticate}, + ) + + if ( + server + and server.is_true_passthrough + and len(mcp_servers or []) == 1 + and not _scope_has_authorization_header(scope) + and not _client_has_per_server_auth_header(server, mcp_server_auth_headers) + ): + if server.is_dcr_bridge: + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={ + "www-authenticate": _get_passthrough_www_authenticate( + scope=scope, + server_name=server_name, + ) + }, + ) + upstream_status, upstream_www_authenticate = await _probe_upstream_auth(server.url or "", "") + if upstream_status == 401 and upstream_www_authenticate: + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"www-authenticate": upstream_www_authenticate}, + ) + + def _get_authorization_header_from_scope(scope: Scope) -> Optional[str]: + """First ``Authorization`` header value in the ASGI scope, or None.""" + for key, value in scope.get("headers", []): + if key.lower() == b"authorization": + return value.decode("latin-1") + return None + + def _scope_has_authorization_header(scope: Scope) -> bool: + return _get_authorization_header_from_scope(scope) is not None + def _get_forwarded_auth_from_scope(scope: Scope) -> Optional[str]: """Return the upstream-bound ``Authorization`` header value, or None. @@ -3498,23 +3740,30 @@ if MCP_AVAILABLE: ``MCPRequestHandler.process_mcp_request``), and forwarding it upstream would leak the proxy key to a third-party MCP server. """ - authorization = None - has_litellm_key_header = False - for key, value in scope.get("headers", []): - key_lower = key.lower() - if key_lower == b"authorization": - authorization = value.decode("latin-1") - elif key_lower == b"x-litellm-api-key": - has_litellm_key_header = True + has_litellm_key_header = any(key.lower() == b"x-litellm-api-key" for key, _ in scope.get("headers", [])) if not has_litellm_key_header: return None - return authorization + return _get_authorization_header_from_scope(scope) + + def _is_delegate_upstream_probe_target(server: MCPServer) -> bool: + """Whether ``server`` is an interactive delegate-auth server whose client-supplied + token should be preflighted upstream. + + Mirrors the anonymous-delegate gate in ``get_allowed_mcp_servers``: the flow is + resolved via ``effective_oauth2_flow`` so an unstamped M2M-shape row fails closed + (its stored client credentials drive egress; the caller's bearer is irrelevant). + """ + return ( + server.auth_type == MCPAuth.oauth2 + and server.delegate_auth_to_upstream is True + and MCPServerManager.effective_oauth2_flow(server) != "client_credentials" + ) async def _probe_upstream_auth( url: str, auth_header: str, timeout: float = 5.0, - ) -> tuple: + ) -> tuple[int, Optional[str]]: """JSON-RPC initialize-probe the upstream URL to check whether the token is accepted. Uses POST so StreamableHTTP MCP servers run the same auth path as a @@ -3544,8 +3793,8 @@ if MCP_AVAILABLE: }, } probe_headers = { - "Authorization": auth_header, "Accept": "application/json, text/event-stream", + **({"Authorization": auth_header} if auth_header else {}), } try: resp = await client.post( @@ -3570,7 +3819,7 @@ if MCP_AVAILABLE: mcp_servers: Optional[List[str]], client_ip: Optional[str], ) -> None: - """Probe pass-through upstream servers in parallel before the MCP session starts. + """Probe pass-through and delegate-auth upstream servers in parallel before the MCP session starts. Only servers the caller's key is already authorized to reach are probed — the list is derived from _get_allowed_mcp_servers so that a user cannot @@ -3578,11 +3827,42 @@ if MCP_AVAILABLE: The MCP SDK commits HTTP 200 headers before invoking handlers, so a 401 can only be returned before that point. This function raises HTTPException(401) - with a WWW-Authenticate header if any upstream rejects the client token. + with a WWW-Authenticate header if any upstream rejects the client token, or 403 + if the upstream accepts it but forbids the caller. Fails-open: network errors are logged and the request is allowed through. + + Delegate-auth servers (``auth_type=oauth2`` + ``delegate_auth_to_upstream``) + are probed with the caller's bare ``Authorization`` bearer. That bearer is only + an upstream token (never a LiteLLM key) when admission took the delegate bypass, + so the delegate target is resolved through ``get_mcp_server_by_name`` -- the same + resolver admission used -- rather than the wider allowed-server prefix/access-group + matching. A name that only reaches a delegate server via server_id or an access + group would have been admitted as a real LiteLLM key, so probing it would leak that + key upstream; requiring the admission-resolver match closes that gap. Without the + probe a rejected token is absorbed by the tools/list handler and masked as an empty + tool list. Gated to single-server routes so one rejected token cannot 401 a + multi-server aggregate connect, matching the OBO preflight gating; the challenge + echoes the requested name so aliased routes get the same resource_metadata URL as + the tokenless preemptive challenge. """ forwarded_auth = _get_forwarded_auth_from_scope(scope) - if not forwarded_auth: + requested_single_target = mcp_servers[0] if mcp_servers is not None and len(mcp_servers) == 1 else None + # The bare Authorization header (no x-litellm-api-key) is a valid upstream token + # only when admission classified it as one, i.e. the single requested name resolves + # to a delegate server under admission's own resolver. Resolve it the same way here + # so a server_id- or access-group-named delegate (which admission would have treated + # as a LiteLLM key) is never probed with that key. + delegate_server = ( + global_mcp_server_manager.get_mcp_server_by_name(requested_single_target, client_ip=client_ip) + if requested_single_target + else None + ) + delegate_auth = ( + _get_authorization_header_from_scope(scope) + if delegate_server is not None and _is_delegate_upstream_probe_target(delegate_server) + else None + ) + if not forwarded_auth and not delegate_auth: return # Use the authorized server set, not the raw user-supplied names, so that @@ -3592,33 +3872,49 @@ if MCP_AVAILABLE: mcp_servers=mcp_servers, client_ip=client_ip, ) - passthrough_servers = [ - srv - for srv in allowed_servers - # Restrict to genuine OAuth pass-through servers (auth_type none + - # Authorization in extra_headers). Gateway-managed OAuth2 servers - # must not receive the ``resource_metadata=`` challenge emitted - # below — they require ``authorization_uri=`` pointing at the - # gateway AS metadata. ``is_oauth_passthrough`` already requires - # ``auth_type in (None, MCPAuth.none)``, which is mutually - # exclusive with ``has_client_credentials`` (oauth2 + M2M flow), - # so M2M servers are implicitly excluded here. - if srv.is_oauth_passthrough - ] - if not passthrough_servers: + passthrough_targets: Tuple[Tuple[MCPServer, str, str], ...] = ( + tuple( + (srv, forwarded_auth, srv.name) + for srv in allowed_servers + # Restrict to genuine OAuth pass-through servers (auth_type none + + # Authorization in extra_headers). Gateway-managed OAuth2 servers + # must not receive the ``resource_metadata=`` challenge emitted + # below — they require ``authorization_uri=`` pointing at the + # gateway AS metadata. ``is_oauth_passthrough`` already requires + # ``auth_type in (None, MCPAuth.none)``, which is mutually + # exclusive with ``has_client_credentials`` (oauth2 + M2M flow), + # so M2M servers are implicitly excluded here. + if srv.is_oauth_passthrough + ) + if forwarded_auth + else () + ) + # Probe the admission-resolved delegate server only when the caller is actually + # authorized for it (present in the IP-filtered allowed set), keyed by server_id. + delegate_targets: Tuple[Tuple[MCPServer, str, str], ...] = ( + tuple( + (srv, delegate_auth, requested_single_target) + for srv in allowed_servers + if delegate_server is not None and srv.server_id == delegate_server.server_id + ) + if delegate_auth and requested_single_target + else () + ) + probe_targets = passthrough_targets + delegate_targets + if not probe_targets: return probe_results = await asyncio.gather( - *[_probe_upstream_auth(srv.url or "", forwarded_auth) for srv in passthrough_servers] + *[_probe_upstream_auth(srv.url or "", auth_header) for srv, auth_header, _ in probe_targets] ) - for srv, (probe_status, _) in zip(passthrough_servers, probe_results): + for (srv, _, challenge_server_name), (probe_status, _) in zip(probe_targets, probe_results): if probe_status == 401: # Token is missing or expired: keep pass-through clients on the # protected-resource discovery flow so they re-authorize against # the upstream IdP metadata proxied by LiteLLM. www_authenticate = _get_passthrough_www_authenticate( scope=scope, - server_name=srv.name, + server_name=challenge_server_name, invalid_token=True, ) raise HTTPException( diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py index 9cb6d404b01..80a469b8c1a 100644 --- a/litellm/proxy/_experimental/mcp_server/utils.py +++ b/litellm/proxy/_experimental/mcp_server/utils.py @@ -214,12 +214,14 @@ def server_applies_tool_allowlist(mcp_server: Any) -> bool: def validate_and_normalize_mcp_server_payload(payload: Any) -> None: """ - Validate and normalize MCP server payload fields (server_name and alias). + Validate and normalize MCP server payload fields (server_name, alias, and + tool_name_to_display_name). This function: 1. Validates that server_name and alias don't contain the MCP_TOOL_PREFIX_SEPARATOR - 2. Normalizes alias by replacing spaces with underscores - 3. Sets default alias if not provided (using server_name as base) + 2. Validates that tool_name_to_display_name values satisfy Bedrock's tool-name pattern + 3. Normalizes alias by replacing spaces with underscores + 4. Sets default alias if not provided (using server_name as base) Args: payload: The payload object containing server_name and alias fields @@ -235,6 +237,10 @@ def validate_and_normalize_mcp_server_payload(payload: Any) -> None: if hasattr(payload, "alias") and payload.alias: validate_mcp_server_name(payload.alias, raise_http_exception=True) + # Tool display name validation: must satisfy Bedrock's tool-name pattern + if hasattr(payload, "tool_name_to_display_name") and payload.tool_name_to_display_name: + validate_tool_display_names(payload.tool_name_to_display_name) + # Alias normalization and defaulting alias = getattr(payload, "alias", None) server_name = getattr(payload, "server_name", None) @@ -409,6 +415,61 @@ def validate_mcp_server_name(server_name: str, raise_http_exception: bool = Fals raise Exception(error_message) +def extract_mcp_tool_result_error_message(result: object) -> Optional[str]: + """The first text content of an ``isError=True`` tool result, or ``None`` + when the result is not an error. + + Accepts both ``mcp.types.CallToolResult`` objects and their dict + equivalents, duck-typed so the ``mcp`` package is not required. + """ + is_error: object = result.get("isError") if isinstance(result, Mapping) else getattr(result, "isError", None) + if is_error is not True: + return None + content: object = result.get("content") if isinstance(result, Mapping) else getattr(result, "content", None) + if isinstance(content, (list, tuple)): + for item in content: + text: object = item.get("text") if isinstance(item, Mapping) else getattr(item, "text", None) + if isinstance(text, str) and text: + return text + return "MCP tool call returned isError=true" + + +TOOL_DISPLAY_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") + + +def validate_tool_display_names(tool_name_to_display_name: Optional[Mapping[str, str]]) -> None: + """ + Validate tool display name overrides against Bedrock's tool-name constraint. + + A display name replaces the tool name sent to the LLM provider, so it must + satisfy the strictest provider requirement in use (Bedrock's + ``[a-zA-Z0-9_-]+``); a name with spaces or other characters saves + successfully but fails every subsequent Bedrock tool call. + + Raises: + HTTPException: If any display name fails the pattern. + """ + if not tool_name_to_display_name: + return + + for original_name, display_name in tool_name_to_display_name.items(): + if display_name and not TOOL_DISPLAY_NAME_PATTERN.match(display_name): + from fastapi import HTTPException + from starlette import status + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": ( + f"Invalid display name '{display_name}' for tool '{original_name}'. " + "Display names may only contain letters, digits, underscores, and " + "hyphens (no spaces or other special characters), since they replace " + "the tool name sent to the LLM provider." + ) + }, + ) + + class MCPMissingUserEnvVarsError(Exception): """Raised when an MCP request can't be built because the calling user has not supplied one or more required per-user environment variables. diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404.html index dd2a01991de..7d4cc0b67af 100644 --- a/litellm/proxy/_experimental/out/404.html +++ b/litellm/proxy/_experimental/out/404.html @@ -1 +1 @@ -404: This page could not be found.LiteLLM Dashboard

404

This page could not be found.

\ No newline at end of file +404: This page could not be found.LiteLLM Dashboard

404

This page could not be found.

\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/404/index.html b/litellm/proxy/_experimental/out/404/index.html index dd2a01991de..7d4cc0b67af 100644 --- a/litellm/proxy/_experimental/out/404/index.html +++ b/litellm/proxy/_experimental/out/404/index.html @@ -1 +1 @@ -404: This page could not be found.LiteLLM Dashboard

404

This page could not be found.

\ No newline at end of file +404: This page could not be found.LiteLLM Dashboard

404

This page could not be found.

\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/__next.!KGRhc2hib2FyZCk.__PAGE__.txt b/litellm/proxy/_experimental/out/__next.!KGRhc2hib2FyZCk.__PAGE__.txt index 55b18876d5b..b87b291253e 100644 --- a/litellm/proxy/_experimental/out/__next.!KGRhc2hib2FyZCk.__PAGE__.txt +++ b/litellm/proxy/_experimental/out/__next.!KGRhc2hib2FyZCk.__PAGE__.txt @@ -1,9 +1,9 @@ 1:"$Sreact.fragment" -2:I[347257,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"ClientPageRoot"] -3:I[871135,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js","/litellm-asset-prefix/_next/static/chunks/0whkizop7gd0~.js","/litellm-asset-prefix/_next/static/chunks/0-ih8xcz_89nt.js","/litellm-asset-prefix/_next/static/chunks/0pd5zl~lciww9.js","/litellm-asset-prefix/_next/static/chunks/02ihc5xweq16v.js","/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","/litellm-asset-prefix/_next/static/chunks/0mzw3maijoev6.js","/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","/litellm-asset-prefix/_next/static/chunks/04amwk-x_vjxu.js","/litellm-asset-prefix/_next/static/chunks/0-dhh1_d1.b1u.js","/litellm-asset-prefix/_next/static/chunks/0pwkd9r.mc_ee.js","/litellm-asset-prefix/_next/static/chunks/011mgw.-67gs_.js","/litellm-asset-prefix/_next/static/chunks/0~-ovi6c4wjt1.js","/litellm-asset-prefix/_next/static/chunks/0_y-b9_d9dsuv.js","/litellm-asset-prefix/_next/static/chunks/0c2apcdkbqq0o.js","/litellm-asset-prefix/_next/static/chunks/0zrbitbm~0koh.js","/litellm-asset-prefix/_next/static/chunks/0sx3mu2_l9g_y.js","/litellm-asset-prefix/_next/static/chunks/0ngre0.s4-ej6.js","/litellm-asset-prefix/_next/static/chunks/0l7em-5kjv49e.js","/litellm-asset-prefix/_next/static/chunks/05t1k89l9tc3s.js","/litellm-asset-prefix/_next/static/chunks/17n.qg70cy9.9.js","/litellm-asset-prefix/_next/static/chunks/00q4mtjboprhm.js","/litellm-asset-prefix/_next/static/chunks/0el08tticy_20.js","/litellm-asset-prefix/_next/static/chunks/0-3i_.uof35pm.js","/litellm-asset-prefix/_next/static/chunks/14566-_ogh-19.js","/litellm-asset-prefix/_next/static/chunks/0w39dn9x3dp9g.js","/litellm-asset-prefix/_next/static/chunks/0q6~n4y84cejn.js","/litellm-asset-prefix/_next/static/chunks/0v1rxqc1hqmrl.js","/litellm-asset-prefix/_next/static/chunks/0c4pfjjue0uc-.js"],"default"] -6:I[897367,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"OutletBoundary"] +2:I[347257,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"ClientPageRoot"] +3:I[871135,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js","/litellm-asset-prefix/_next/static/chunks/0kc37~1yrtr2p.js","/litellm-asset-prefix/_next/static/chunks/04s-iyzsr4cq~.js","/litellm-asset-prefix/_next/static/chunks/16zj68af4snfa.js","/litellm-asset-prefix/_next/static/chunks/003_1s9xbht43.js","/litellm-asset-prefix/_next/static/chunks/0tmaomqtwbi33.js","/litellm-asset-prefix/_next/static/chunks/02-2~p5k.ielz.js","/litellm-asset-prefix/_next/static/chunks/0op63kdo3uwng.js","/litellm-asset-prefix/_next/static/chunks/112_-0alpxot8.js","/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","/litellm-asset-prefix/_next/static/chunks/12yfh0_n50ojz.js","/litellm-asset-prefix/_next/static/chunks/06w8_.601z7_i.js","/litellm-asset-prefix/_next/static/chunks/0nht59ws0elww.js","/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","/litellm-asset-prefix/_next/static/chunks/18187o3gb9vc5.js","/litellm-asset-prefix/_next/static/chunks/0._ir~nvcseg7.js","/litellm-asset-prefix/_next/static/chunks/0vffq7buvlg04.js","/litellm-asset-prefix/_next/static/chunks/122djf0bncn-8.js","/litellm-asset-prefix/_next/static/chunks/0axk76owb7jv..js","/litellm-asset-prefix/_next/static/chunks/0el08tticy_20.js","/litellm-asset-prefix/_next/static/chunks/0sx3mu2_l9g_y.js","/litellm-asset-prefix/_next/static/chunks/0sqw622fcvsv4.js","/litellm-asset-prefix/_next/static/chunks/0.bx44y-6~tug.js","/litellm-asset-prefix/_next/static/chunks/0l7em-5kjv49e.js","/litellm-asset-prefix/_next/static/chunks/14g~hmf3h_efw.js","/litellm-asset-prefix/_next/static/chunks/0q6~n4y84cejn.js","/litellm-asset-prefix/_next/static/chunks/0cmepm.jkel-i.js","/litellm-asset-prefix/_next/static/chunks/0onea0n77pqw1.js","/litellm-asset-prefix/_next/static/chunks/02-u6qtmsnqn0.js","/litellm-asset-prefix/_next/static/chunks/0mh1wnrvmv_y7.js","/litellm-asset-prefix/_next/static/chunks/05q6y.kb.q2s..js","/litellm-asset-prefix/_next/static/chunks/0rehsq9xe1kde.js","/litellm-asset-prefix/_next/static/chunks/0nk7-_~gcxbz0.js","/litellm-asset-prefix/_next/static/chunks/0~r95y0t-0dlp.js"],"default"] +6:I[897367,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"OutletBoundary"] 7:"$Sreact.suspense" -0:{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/011mgw.-67gs_.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/0~-ovi6c4wjt1.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0_y-b9_d9dsuv.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/0c2apcdkbqq0o.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/0zrbitbm~0koh.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0sx3mu2_l9g_y.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0ngre0.s4-ej6.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0l7em-5kjv49e.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/05t1k89l9tc3s.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/17n.qg70cy9.9.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/00q4mtjboprhm.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/0el08tticy_20.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/0-3i_.uof35pm.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/14566-_ogh-19.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/0w39dn9x3dp9g.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/0q6~n4y84cejn.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/0v1rxqc1hqmrl.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/0c4pfjjue0uc-.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5rDiFx0t_mOGYmV_8kSkw"} +0:{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0vffq7buvlg04.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/122djf0bncn-8.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0axk76owb7jv..js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/0el08tticy_20.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/0sx3mu2_l9g_y.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0sqw622fcvsv4.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0.bx44y-6~tug.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0l7em-5kjv49e.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/14g~hmf3h_efw.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/0q6~n4y84cejn.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/0cmepm.jkel-i.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/0onea0n77pqw1.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/02-u6qtmsnqn0.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/0mh1wnrvmv_y7.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/05q6y.kb.q2s..js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/0rehsq9xe1kde.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/0nk7-_~gcxbz0.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/0~r95y0t-0dlp.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"N7WCdfNd30Hp6HEF5tFIL"} 4:{} 5:"$0:rsc:props:children:0:props:serverProvidedParams:params" 8:null diff --git a/litellm/proxy/_experimental/out/__next.!KGRhc2hib2FyZCk.txt b/litellm/proxy/_experimental/out/__next.!KGRhc2hib2FyZCk.txt index 55176f9118b..3413c4c285d 100644 --- a/litellm/proxy/_experimental/out/__next.!KGRhc2hib2FyZCk.txt +++ b/litellm/proxy/_experimental/out/__next.!KGRhc2hib2FyZCk.txt @@ -1,7 +1,7 @@ 1:"$Sreact.fragment" -2:I[92825,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"ClientSegmentRoot"] -3:I[216370,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js","/litellm-asset-prefix/_next/static/chunks/0whkizop7gd0~.js","/litellm-asset-prefix/_next/static/chunks/0-ih8xcz_89nt.js","/litellm-asset-prefix/_next/static/chunks/0pd5zl~lciww9.js","/litellm-asset-prefix/_next/static/chunks/02ihc5xweq16v.js","/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","/litellm-asset-prefix/_next/static/chunks/0mzw3maijoev6.js","/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","/litellm-asset-prefix/_next/static/chunks/04amwk-x_vjxu.js","/litellm-asset-prefix/_next/static/chunks/0-dhh1_d1.b1u.js","/litellm-asset-prefix/_next/static/chunks/0pwkd9r.mc_ee.js"],"default"] -4:I[339756,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default"] -5:I[837457,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default"] -0:{"rsc":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0whkizop7gd0~.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/0-ih8xcz_89nt.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0pd5zl~lciww9.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/02ihc5xweq16v.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0mzw3maijoev6.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/04amwk-x_vjxu.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/0-dhh1_d1.b1u.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/0pwkd9r.mc_ee.js","async":true}]],["$","$L2",null,{"Component":"$3","slots":{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]},"serverProvidedParams":{"params":{},"promises":["$@6"]}}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5rDiFx0t_mOGYmV_8kSkw"} +2:I[92825,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"ClientSegmentRoot"] +3:I[216370,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js","/litellm-asset-prefix/_next/static/chunks/0kc37~1yrtr2p.js","/litellm-asset-prefix/_next/static/chunks/04s-iyzsr4cq~.js","/litellm-asset-prefix/_next/static/chunks/16zj68af4snfa.js","/litellm-asset-prefix/_next/static/chunks/003_1s9xbht43.js","/litellm-asset-prefix/_next/static/chunks/0tmaomqtwbi33.js","/litellm-asset-prefix/_next/static/chunks/02-2~p5k.ielz.js","/litellm-asset-prefix/_next/static/chunks/0op63kdo3uwng.js","/litellm-asset-prefix/_next/static/chunks/112_-0alpxot8.js","/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","/litellm-asset-prefix/_next/static/chunks/12yfh0_n50ojz.js","/litellm-asset-prefix/_next/static/chunks/06w8_.601z7_i.js","/litellm-asset-prefix/_next/static/chunks/0nht59ws0elww.js","/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","/litellm-asset-prefix/_next/static/chunks/18187o3gb9vc5.js","/litellm-asset-prefix/_next/static/chunks/0._ir~nvcseg7.js"],"default"] +4:I[339756,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"] +5:I[837457,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"] +0:{"rsc":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0kc37~1yrtr2p.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/04s-iyzsr4cq~.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/16zj68af4snfa.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/003_1s9xbht43.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/0tmaomqtwbi33.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/02-2~p5k.ielz.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0op63kdo3uwng.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/112_-0alpxot8.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/12yfh0_n50ojz.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/06w8_.601z7_i.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/0nht59ws0elww.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/18187o3gb9vc5.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/0._ir~nvcseg7.js","async":true}]],["$","$L2",null,{"Component":"$3","slots":{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]},"serverProvidedParams":{"params":{},"promises":["$@6"]}}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"N7WCdfNd30Hp6HEF5tFIL"} 6:"$0:rsc:props:children:1:props:serverProvidedParams:params" diff --git a/litellm/proxy/_experimental/out/__next._full.txt b/litellm/proxy/_experimental/out/__next._full.txt index f1ff1ff8411..8aebbcdc258 100644 --- a/litellm/proxy/_experimental/out/__next._full.txt +++ b/litellm/proxy/_experimental/out/__next._full.txt @@ -1,30 +1,31 @@ 1:"$Sreact.fragment" -2:I[867271,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default"] -3:I[71195,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default"] -4:I[557951,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"AuthProvider"] -5:I[339756,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default"] -6:I[837457,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default"] -7:I[92825,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"ClientSegmentRoot"] -8:I[216370,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js","/litellm-asset-prefix/_next/static/chunks/0whkizop7gd0~.js","/litellm-asset-prefix/_next/static/chunks/0-ih8xcz_89nt.js","/litellm-asset-prefix/_next/static/chunks/0pd5zl~lciww9.js","/litellm-asset-prefix/_next/static/chunks/02ihc5xweq16v.js","/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","/litellm-asset-prefix/_next/static/chunks/0mzw3maijoev6.js","/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","/litellm-asset-prefix/_next/static/chunks/04amwk-x_vjxu.js","/litellm-asset-prefix/_next/static/chunks/0-dhh1_d1.b1u.js","/litellm-asset-prefix/_next/static/chunks/0pwkd9r.mc_ee.js"],"default"] -c:I[168027,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default",1] +2:I[867271,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"] +3:I[71195,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"] +4:I[557951,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"AuthProvider"] +5:I[339756,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"] +6:I[837457,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"] +7:I[92825,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"ClientSegmentRoot"] +8:I[216370,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js","/litellm-asset-prefix/_next/static/chunks/0kc37~1yrtr2p.js","/litellm-asset-prefix/_next/static/chunks/04s-iyzsr4cq~.js","/litellm-asset-prefix/_next/static/chunks/16zj68af4snfa.js","/litellm-asset-prefix/_next/static/chunks/003_1s9xbht43.js","/litellm-asset-prefix/_next/static/chunks/0tmaomqtwbi33.js","/litellm-asset-prefix/_next/static/chunks/02-2~p5k.ielz.js","/litellm-asset-prefix/_next/static/chunks/0op63kdo3uwng.js","/litellm-asset-prefix/_next/static/chunks/112_-0alpxot8.js","/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","/litellm-asset-prefix/_next/static/chunks/12yfh0_n50ojz.js","/litellm-asset-prefix/_next/static/chunks/06w8_.601z7_i.js","/litellm-asset-prefix/_next/static/chunks/0nht59ws0elww.js","/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","/litellm-asset-prefix/_next/static/chunks/18187o3gb9vc5.js","/litellm-asset-prefix/_next/static/chunks/0._ir~nvcseg7.js"],"default"] +d:I[168027,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default",1] :HL["/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","style"] -:HL["/litellm-asset-prefix/_next/static/chunks/0i77.0u.82o9u.css","style"] +:HL["/litellm-asset-prefix/_next/static/chunks/15j3hwz2dxrik.css","style"] :HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.0q-301v4kxxnr.woff2","font",{"crossOrigin":"","type":"font/woff2"}] -0:{"P":null,"c":["",""],"q":"","i":false,"f":[[["",{"children":["(dashboard)",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/0i77.0u.82o9u.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]}]}]]}],{"children":[["$","$1","c",{"children":[[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0whkizop7gd0~.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/0-ih8xcz_89nt.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0pd5zl~lciww9.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/02ihc5xweq16v.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0mzw3maijoev6.js","async":true,"nonce":"$undefined"}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","async":true,"nonce":"$undefined"}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/04amwk-x_vjxu.js","async":true,"nonce":"$undefined"}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/0-dhh1_d1.b1u.js","async":true,"nonce":"$undefined"}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/0pwkd9r.mc_ee.js","async":true,"nonce":"$undefined"}]],["$","$L7",null,{"Component":"$8","slots":{"children":["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]},"serverProvidedParams":{"params":{},"promises":["$@9"]}}]]}],{"children":["$La",{},null,false,null]},null,false,null]},null,false,null],"$Lb",false]],"m":"$undefined","G":["$c",["$Ld","$Le"]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"5rDiFx0t_mOGYmV_8kSkw"} -f:I[347257,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"ClientPageRoot"] -10:I[871135,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js","/litellm-asset-prefix/_next/static/chunks/0whkizop7gd0~.js","/litellm-asset-prefix/_next/static/chunks/0-ih8xcz_89nt.js","/litellm-asset-prefix/_next/static/chunks/0pd5zl~lciww9.js","/litellm-asset-prefix/_next/static/chunks/02ihc5xweq16v.js","/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","/litellm-asset-prefix/_next/static/chunks/0mzw3maijoev6.js","/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","/litellm-asset-prefix/_next/static/chunks/04amwk-x_vjxu.js","/litellm-asset-prefix/_next/static/chunks/0-dhh1_d1.b1u.js","/litellm-asset-prefix/_next/static/chunks/0pwkd9r.mc_ee.js","/litellm-asset-prefix/_next/static/chunks/011mgw.-67gs_.js","/litellm-asset-prefix/_next/static/chunks/0~-ovi6c4wjt1.js","/litellm-asset-prefix/_next/static/chunks/0_y-b9_d9dsuv.js","/litellm-asset-prefix/_next/static/chunks/0c2apcdkbqq0o.js","/litellm-asset-prefix/_next/static/chunks/0zrbitbm~0koh.js","/litellm-asset-prefix/_next/static/chunks/0sx3mu2_l9g_y.js","/litellm-asset-prefix/_next/static/chunks/0ngre0.s4-ej6.js","/litellm-asset-prefix/_next/static/chunks/0l7em-5kjv49e.js","/litellm-asset-prefix/_next/static/chunks/05t1k89l9tc3s.js","/litellm-asset-prefix/_next/static/chunks/17n.qg70cy9.9.js","/litellm-asset-prefix/_next/static/chunks/00q4mtjboprhm.js","/litellm-asset-prefix/_next/static/chunks/0el08tticy_20.js","/litellm-asset-prefix/_next/static/chunks/0-3i_.uof35pm.js","/litellm-asset-prefix/_next/static/chunks/14566-_ogh-19.js","/litellm-asset-prefix/_next/static/chunks/0w39dn9x3dp9g.js","/litellm-asset-prefix/_next/static/chunks/0q6~n4y84cejn.js","/litellm-asset-prefix/_next/static/chunks/0v1rxqc1hqmrl.js","/litellm-asset-prefix/_next/static/chunks/0c4pfjjue0uc-.js"],"default"] -13:I[897367,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"OutletBoundary"] -14:"$Sreact.suspense" -16:I[897367,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"ViewportBoundary"] -18:I[897367,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"MetadataBoundary"] -a:["$","$1","c",{"children":[["$","$Lf",null,{"Component":"$10","serverProvidedParams":{"searchParams":{},"params":"$0:f:0:1:1:children:0:props:children:1:props:serverProvidedParams:params","promises":["$@11","$@12"]}}],[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/011mgw.-67gs_.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/0~-ovi6c4wjt1.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0_y-b9_d9dsuv.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/0c2apcdkbqq0o.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/0zrbitbm~0koh.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0sx3mu2_l9g_y.js","async":true,"nonce":"$undefined"}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0ngre0.s4-ej6.js","async":true,"nonce":"$undefined"}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0l7em-5kjv49e.js","async":true,"nonce":"$undefined"}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/05t1k89l9tc3s.js","async":true,"nonce":"$undefined"}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/17n.qg70cy9.9.js","async":true,"nonce":"$undefined"}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/00q4mtjboprhm.js","async":true,"nonce":"$undefined"}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/0el08tticy_20.js","async":true,"nonce":"$undefined"}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/0-3i_.uof35pm.js","async":true,"nonce":"$undefined"}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/14566-_ogh-19.js","async":true,"nonce":"$undefined"}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/0w39dn9x3dp9g.js","async":true,"nonce":"$undefined"}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/0q6~n4y84cejn.js","async":true,"nonce":"$undefined"}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/0v1rxqc1hqmrl.js","async":true,"nonce":"$undefined"}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/0c4pfjjue0uc-.js","async":true,"nonce":"$undefined"}]],["$","$L13",null,{"children":["$","$14",null,{"name":"Next.MetadataOutlet","children":"$@15"}]}]]}] -b:["$","$1","h",{"children":[null,["$","$L16",null,{"children":"$L17"}],["$","div",null,{"hidden":true,"children":["$","$L18",null,{"children":["$","$14",null,{"name":"Next.Metadata","children":"$L19"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}] -d:["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}] -e:["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/0i77.0u.82o9u.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}] -9:"$0:f:0:1:1:children:0:props:children:1:props:serverProvidedParams:params" -11:{} -12:"$0:f:0:1:1:children:0:props:children:1:props:serverProvidedParams:params" -17:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] -1a:I[27201,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"IconMark"] -15:null -19:[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0~dgapwhi~75y.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"/get_favicon"}],["$","$L1a","4",{}]] +0:{"P":null,"c":["",""],"q":"","i":false,"f":[[["",{"children":["(dashboard)",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/15j3hwz2dxrik.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]}]}]]}],{"children":[["$","$1","c",{"children":[[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0kc37~1yrtr2p.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/04s-iyzsr4cq~.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/16zj68af4snfa.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/003_1s9xbht43.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/0tmaomqtwbi33.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/02-2~p5k.ielz.js","async":true,"nonce":"$undefined"}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0op63kdo3uwng.js","async":true,"nonce":"$undefined"}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/112_-0alpxot8.js","async":true,"nonce":"$undefined"}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","async":true,"nonce":"$undefined"}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/12yfh0_n50ojz.js","async":true,"nonce":"$undefined"}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/06w8_.601z7_i.js","async":true,"nonce":"$undefined"}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/0nht59ws0elww.js","async":true,"nonce":"$undefined"}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","async":true,"nonce":"$undefined"}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/18187o3gb9vc5.js","async":true,"nonce":"$undefined"}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/0._ir~nvcseg7.js","async":true,"nonce":"$undefined"}]],["$","$L7",null,{"Component":"$8","slots":{"children":["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],"$L9"],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]},"serverProvidedParams":{"params":{},"promises":["$@a"]}}]]}],{"children":["$Lb",{},null,false,null]},null,false,null]},null,false,null],"$Lc",false]],"m":"$undefined","G":["$d",["$Le","$Lf"]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"N7WCdfNd30Hp6HEF5tFIL"} +10:I[347257,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"ClientPageRoot"] +11:I[871135,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js","/litellm-asset-prefix/_next/static/chunks/0kc37~1yrtr2p.js","/litellm-asset-prefix/_next/static/chunks/04s-iyzsr4cq~.js","/litellm-asset-prefix/_next/static/chunks/16zj68af4snfa.js","/litellm-asset-prefix/_next/static/chunks/003_1s9xbht43.js","/litellm-asset-prefix/_next/static/chunks/0tmaomqtwbi33.js","/litellm-asset-prefix/_next/static/chunks/02-2~p5k.ielz.js","/litellm-asset-prefix/_next/static/chunks/0op63kdo3uwng.js","/litellm-asset-prefix/_next/static/chunks/112_-0alpxot8.js","/litellm-asset-prefix/_next/static/chunks/043q3g5-5-aju.js","/litellm-asset-prefix/_next/static/chunks/12yfh0_n50ojz.js","/litellm-asset-prefix/_next/static/chunks/06w8_.601z7_i.js","/litellm-asset-prefix/_next/static/chunks/0nht59ws0elww.js","/litellm-asset-prefix/_next/static/chunks/0lg.6rbfsd-l9.js","/litellm-asset-prefix/_next/static/chunks/18187o3gb9vc5.js","/litellm-asset-prefix/_next/static/chunks/0._ir~nvcseg7.js","/litellm-asset-prefix/_next/static/chunks/0vffq7buvlg04.js","/litellm-asset-prefix/_next/static/chunks/122djf0bncn-8.js","/litellm-asset-prefix/_next/static/chunks/0axk76owb7jv..js","/litellm-asset-prefix/_next/static/chunks/0el08tticy_20.js","/litellm-asset-prefix/_next/static/chunks/0sx3mu2_l9g_y.js","/litellm-asset-prefix/_next/static/chunks/0sqw622fcvsv4.js","/litellm-asset-prefix/_next/static/chunks/0.bx44y-6~tug.js","/litellm-asset-prefix/_next/static/chunks/0l7em-5kjv49e.js","/litellm-asset-prefix/_next/static/chunks/14g~hmf3h_efw.js","/litellm-asset-prefix/_next/static/chunks/0q6~n4y84cejn.js","/litellm-asset-prefix/_next/static/chunks/0cmepm.jkel-i.js","/litellm-asset-prefix/_next/static/chunks/0onea0n77pqw1.js","/litellm-asset-prefix/_next/static/chunks/02-u6qtmsnqn0.js","/litellm-asset-prefix/_next/static/chunks/0mh1wnrvmv_y7.js","/litellm-asset-prefix/_next/static/chunks/05q6y.kb.q2s..js","/litellm-asset-prefix/_next/static/chunks/0rehsq9xe1kde.js","/litellm-asset-prefix/_next/static/chunks/0nk7-_~gcxbz0.js","/litellm-asset-prefix/_next/static/chunks/0~r95y0t-0dlp.js"],"default"] +14:I[897367,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"OutletBoundary"] +15:"$Sreact.suspense" +17:I[897367,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"ViewportBoundary"] +19:I[897367,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"MetadataBoundary"] +9:["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:children:props:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:props:children:props:children:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}] +b:["$","$1","c",{"children":[["$","$L10",null,{"Component":"$11","serverProvidedParams":{"searchParams":{},"params":"$0:f:0:1:1:children:0:props:children:1:props:serverProvidedParams:params","promises":["$@12","$@13"]}}],[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0vffq7buvlg04.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/122djf0bncn-8.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0axk76owb7jv..js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/0el08tticy_20.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/0sx3mu2_l9g_y.js","async":true,"nonce":"$undefined"}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0sqw622fcvsv4.js","async":true,"nonce":"$undefined"}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0.bx44y-6~tug.js","async":true,"nonce":"$undefined"}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0l7em-5kjv49e.js","async":true,"nonce":"$undefined"}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/14g~hmf3h_efw.js","async":true,"nonce":"$undefined"}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/0q6~n4y84cejn.js","async":true,"nonce":"$undefined"}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/0cmepm.jkel-i.js","async":true,"nonce":"$undefined"}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/0onea0n77pqw1.js","async":true,"nonce":"$undefined"}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/02-u6qtmsnqn0.js","async":true,"nonce":"$undefined"}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/0mh1wnrvmv_y7.js","async":true,"nonce":"$undefined"}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/05q6y.kb.q2s..js","async":true,"nonce":"$undefined"}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/0rehsq9xe1kde.js","async":true,"nonce":"$undefined"}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/0nk7-_~gcxbz0.js","async":true,"nonce":"$undefined"}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/0~r95y0t-0dlp.js","async":true,"nonce":"$undefined"}]],["$","$L14",null,{"children":["$","$15",null,{"name":"Next.MetadataOutlet","children":"$@16"}]}]]}] +c:["$","$1","h",{"children":[null,["$","$L17",null,{"children":"$L18"}],["$","div",null,{"hidden":true,"children":["$","$L19",null,{"children":["$","$15",null,{"name":"Next.Metadata","children":"$L1a"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}] +e:["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}] +f:["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/15j3hwz2dxrik.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}] +a:"$0:f:0:1:1:children:0:props:children:1:props:serverProvidedParams:params" +12:{} +13:"$0:f:0:1:1:children:0:props:children:1:props:serverProvidedParams:params" +18:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] +1b:I[27201,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"IconMark"] +16:null +1a:[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0~dgapwhi~75y.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"/get_favicon"}],["$","$L1b","4",{}]] diff --git a/litellm/proxy/_experimental/out/__next._head.txt b/litellm/proxy/_experimental/out/__next._head.txt index 27acd699792..51067b68caa 100644 --- a/litellm/proxy/_experimental/out/__next._head.txt +++ b/litellm/proxy/_experimental/out/__next._head.txt @@ -1,6 +1,6 @@ 1:"$Sreact.fragment" -2:I[897367,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"ViewportBoundary"] -3:I[897367,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"MetadataBoundary"] +2:I[897367,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"ViewportBoundary"] +3:I[897367,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"MetadataBoundary"] 4:"$Sreact.suspense" -5:I[27201,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"IconMark"] -0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0~dgapwhi~75y.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"/get_favicon"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5rDiFx0t_mOGYmV_8kSkw"} +5:I[27201,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"IconMark"] +0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0~dgapwhi~75y.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"/get_favicon"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"N7WCdfNd30Hp6HEF5tFIL"} diff --git a/litellm/proxy/_experimental/out/__next._index.txt b/litellm/proxy/_experimental/out/__next._index.txt index 9714fd22643..ac9a9fe0dca 100644 --- a/litellm/proxy/_experimental/out/__next._index.txt +++ b/litellm/proxy/_experimental/out/__next._index.txt @@ -1,9 +1,9 @@ 1:"$Sreact.fragment" -2:I[867271,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default"] -3:I[71195,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default"] -4:I[557951,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"AuthProvider"] -5:I[339756,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default"] -6:I[837457,["/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js"],"default"] +2:I[867271,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"] +3:I[71195,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"] +4:I[557951,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"AuthProvider"] +5:I[339756,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"] +6:I[837457,["/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js"],"default"] :HL["/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","style"] -:HL["/litellm-asset-prefix/_next/static/chunks/0i77.0u.82o9u.css","style"] -0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/0i77.0u.82o9u.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0n.a~e5dwfnkn.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/0.4.bbjx7y007.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0pidya1qvuvx8.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","template":["$","$L6",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5rDiFx0t_mOGYmV_8kSkw"} +:HL["/litellm-asset-prefix/_next/static/chunks/15j3hwz2dxrik.css","style"] +0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/15j3hwz2dxrik.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/09qhs_ev_bxr8.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/0a6iga_s7xld1.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/0hpxif-db_y5-.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","template":["$","$L6",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"N7WCdfNd30Hp6HEF5tFIL"} diff --git a/litellm/proxy/_experimental/out/__next._tree.txt b/litellm/proxy/_experimental/out/__next._tree.txt index c8aadb1d1e2..70be0036004 100644 --- a/litellm/proxy/_experimental/out/__next._tree.txt +++ b/litellm/proxy/_experimental/out/__next._tree.txt @@ -1,4 +1,4 @@ :HL["/litellm-asset-prefix/_next/static/chunks/05qmwjqau64bz.css","style"] -:HL["/litellm-asset-prefix/_next/static/chunks/0i77.0u.82o9u.css","style"] +:HL["/litellm-asset-prefix/_next/static/chunks/15j3hwz2dxrik.css","style"] :HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.0q-301v4kxxnr.woff2","font",{"crossOrigin":"","type":"font/woff2"}] -0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"5rDiFx0t_mOGYmV_8kSkw"} +0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"N7WCdfNd30Hp6HEF5tFIL"} diff --git a/litellm/proxy/_experimental/out/_next/static/5rDiFx0t_mOGYmV_8kSkw/_buildManifest.js b/litellm/proxy/_experimental/out/_next/static/N7WCdfNd30Hp6HEF5tFIL/_buildManifest.js similarity index 100% rename from litellm/proxy/_experimental/out/_next/static/5rDiFx0t_mOGYmV_8kSkw/_buildManifest.js rename to litellm/proxy/_experimental/out/_next/static/N7WCdfNd30Hp6HEF5tFIL/_buildManifest.js diff --git a/litellm/proxy/_experimental/out/_next/static/5rDiFx0t_mOGYmV_8kSkw/_clientMiddlewareManifest.js b/litellm/proxy/_experimental/out/_next/static/N7WCdfNd30Hp6HEF5tFIL/_clientMiddlewareManifest.js similarity index 100% rename from litellm/proxy/_experimental/out/_next/static/5rDiFx0t_mOGYmV_8kSkw/_clientMiddlewareManifest.js rename to litellm/proxy/_experimental/out/_next/static/N7WCdfNd30Hp6HEF5tFIL/_clientMiddlewareManifest.js diff --git a/litellm/proxy/_experimental/out/_next/static/5rDiFx0t_mOGYmV_8kSkw/_ssgManifest.js b/litellm/proxy/_experimental/out/_next/static/N7WCdfNd30Hp6HEF5tFIL/_ssgManifest.js similarity index 100% rename from litellm/proxy/_experimental/out/_next/static/5rDiFx0t_mOGYmV_8kSkw/_ssgManifest.js rename to litellm/proxy/_experimental/out/_next/static/N7WCdfNd30Hp6HEF5tFIL/_ssgManifest.js diff --git a/litellm/proxy/_experimental/out/_next/static/chunks/0-3i_.uof35pm.js b/litellm/proxy/_experimental/out/_next/static/chunks/0-3i_.uof35pm.js deleted file mode 100644 index aaaafac2d13..00000000000 --- a/litellm/proxy/_experimental/out/_next/static/chunks/0-3i_.uof35pm.js +++ /dev/null @@ -1,2 +0,0 @@ -(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,184163,e=>{"use strict";e.i(247167);var t=e.i(931067),r=e.i(271645);let n={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M505.7 661a8 8 0 0012.6 0l112-141.7c4.1-5.2.4-12.9-6.3-12.9h-74.1V168c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v338.3H400c-6.7 0-10.4 7.7-6.3 12.9l112 141.8zM878 626h-60c-4.4 0-8 3.6-8 8v154H214V634c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v198c0 17.7 14.3 32 32 32h684c17.7 0 32-14.3 32-32V634c0-4.4-3.6-8-8-8z"}}]},name:"download",theme:"outlined"};var i=e.i(9583),s=r.forwardRef(function(e,s){return r.createElement(i.default,(0,t.default)({},e,{ref:s,icon:n}))});e.s(["default",0,s],184163)},309821,e=>{"use strict";e.i(247167);var t=e.i(271645),r=e.i(135551),n=e.i(201072),i=e.i(121229),s=e.i(726289),a=e.i(864517),l=e.i(343794),o=e.i(529681),c=e.i(242064),u=e.i(931067),d=e.i(209428),m=e.i(703923),f={percent:0,prefixCls:"rc-progress",strokeColor:"#2db7f5",strokeLinecap:"round",strokeWidth:1,trailColor:"#D9D9D9",trailWidth:1,gapPosition:"bottom"},h=function(){var e=(0,t.useRef)([]),r=(0,t.useRef)(null);return(0,t.useEffect)(function(){var t=Date.now(),n=!1;e.current.forEach(function(e){if(e){n=!0;var i=e.style;i.transitionDuration=".3s, .3s, .3s, .06s",r.current&&t-r.current<100&&(i.transitionDuration="0s, 0s")}}),n&&(r.current=Date.now())}),e.current},p=e.i(410160),g=e.i(392221),x=e.i(654310),v=0,y=(0,x.default)();let b=function(e){var r=t.useState(),n=(0,g.default)(r,2),i=n[0],s=n[1];return t.useEffect(function(){var e;s("rc_progress_".concat((y?(e=v,v+=1):e="TEST_OR_SSR",e)))},[]),e||i};var _=function(e){var r=e.bg,n=e.children;return t.createElement("div",{style:{width:"100%",height:"100%",background:r}},n)};function j(e,t){return Object.keys(e).map(function(r){var n=parseFloat(r),i="".concat(Math.floor(n*t),"%");return"".concat(e[r]," ").concat(i)})}var w=t.forwardRef(function(e,r){var n=e.prefixCls,i=e.color,s=e.gradientId,a=e.radius,l=e.style,o=e.ptg,c=e.strokeLinecap,u=e.strokeWidth,d=e.size,m=e.gapDegree,f=i&&"object"===(0,p.default)(i),h=d/2,g=t.createElement("circle",{className:"".concat(n,"-circle-path"),r:a,cx:h,cy:h,stroke:f?"#FFF":void 0,strokeLinecap:c,strokeWidth:u,opacity:+(0!==o),style:l,ref:r});if(!f)return g;var x="".concat(s,"-conic"),v=j(i,(360-m)/360),y=j(i,1),b="conic-gradient(from ".concat(m?"".concat(180+m/2,"deg"):"0deg",", ").concat(v.join(", "),")"),w="linear-gradient(to ".concat(m?"bottom":"top",", ").concat(y.join(", "),")");return t.createElement(t.Fragment,null,t.createElement("mask",{id:x},g),t.createElement("foreignObject",{x:0,y:0,width:d,height:d,mask:"url(#".concat(x,")")},t.createElement(_,{bg:w},t.createElement(_,{bg:b}))))}),k=function(e,t,r,n,i,s,a,l,o,c){var u=arguments.length>10&&void 0!==arguments[10]?arguments[10]:0,d=(100-n)/100*t;return"round"===o&&100!==n&&(d+=c/2)>=t&&(d=t-.01),{stroke:"string"==typeof l?l:void 0,strokeDasharray:"".concat(t,"px ").concat(e),strokeDashoffset:d+u,transform:"rotate(".concat(i+r/100*360*((360-s)/360)+(0===s?0:({bottom:0,top:180,left:90,right:-90})[a]),"deg)"),transformOrigin:"".concat(50,"px ").concat(50,"px"),transition:"stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s",fillOpacity:0}},C=["id","prefixCls","steps","strokeWidth","trailWidth","gapDegree","gapPosition","trailColor","strokeLinecap","style","className","strokeColor","percent"];function S(e){var t=null!=e?e:[];return Array.isArray(t)?t:[t]}let E=function(e){var r,n,i,s,a=(0,d.default)((0,d.default)({},f),e),o=a.id,c=a.prefixCls,g=a.steps,x=a.strokeWidth,v=a.trailWidth,y=a.gapDegree,_=void 0===y?0:y,j=a.gapPosition,E=a.trailColor,O=a.strokeLinecap,N=a.style,I=a.className,T=a.strokeColor,R=a.percent,P=(0,m.default)(a,C),D=b(o),$="".concat(D,"-gradient"),A=50-x/2,F=2*Math.PI*A,L=_>0?90+_/2:-90,M=(360-_)/360*F,B="object"===(0,p.default)(g)?g:{count:g,gap:2},U=B.count,z=B.gap,V=S(R),H=S(T),W=H.find(function(e){return e&&"object"===(0,p.default)(e)}),K=W&&"object"===(0,p.default)(W)?"butt":O,q=k(F,M,0,100,L,_,j,E,K,x),X=h();return t.createElement("svg",(0,u.default)({className:(0,l.default)("".concat(c,"-circle"),I),viewBox:"0 0 ".concat(100," ").concat(100),style:N,id:o,role:"presentation"},P),!U&&t.createElement("circle",{className:"".concat(c,"-circle-trail"),r:A,cx:50,cy:50,stroke:E,strokeLinecap:K,strokeWidth:v||x,style:q}),U?(r=Math.round(U*(V[0]/100)),n=100/U,i=0,Array(U).fill(null).map(function(e,s){var a=s<=r-1?H[0]:E,l=a&&"object"===(0,p.default)(a)?"url(#".concat($,")"):void 0,o=k(F,M,i,n,L,_,j,a,"butt",x,z);return i+=(M-o.strokeDashoffset+z)*100/M,t.createElement("circle",{key:s,className:"".concat(c,"-circle-path"),r:A,cx:50,cy:50,stroke:l,strokeWidth:x,opacity:1,style:o,ref:function(e){X[s]=e}})})):(s=0,V.map(function(e,r){var n=H[r]||H[H.length-1],i=k(F,M,s,e,L,_,j,n,K,x);return s+=e,t.createElement(w,{key:r,color:n,ptg:e,radius:A,prefixCls:c,gradientId:$,style:i,strokeLinecap:K,strokeWidth:x,gapDegree:_,ref:function(e){X[r]=e},size:100})}).reverse()))};var O=e.i(491816);e.i(765846);var N=e.i(896091);function I(e){return!e||e<0?0:e>100?100:e}function T({success:e,successPercent:t}){let r=t;return e&&"progress"in e&&(r=e.progress),e&&"percent"in e&&(r=e.percent),r}let R=(e,t,r)=>{var n,i,s,a;let l=-1,o=-1;if("step"===t){let t=r.steps,n=r.strokeWidth;"string"==typeof e||void 0===e?(l="small"===e?2:14,o=null!=n?n:8):"number"==typeof e?[l,o]=[e,e]:[l=14,o=8]=Array.isArray(e)?e:[e.width,e.height],l*=t}else if("line"===t){let t=null==r?void 0:r.strokeWidth;"string"==typeof e||void 0===e?o=t||("small"===e?6:8):"number"==typeof e?[l,o]=[e,e]:[l=-1,o=8]=Array.isArray(e)?e:[e.width,e.height]}else("circle"===t||"dashboard"===t)&&("string"==typeof e||void 0===e?[l,o]="small"===e?[60,60]:[120,120]:"number"==typeof e?[l,o]=[e,e]:Array.isArray(e)&&(l=null!=(i=null!=(n=e[0])?n:e[1])?i:120,o=null!=(a=null!=(s=e[0])?s:e[1])?a:120));return[l,o]},P=e=>{let{prefixCls:r,trailColor:n=null,strokeLinecap:i="round",gapPosition:s,gapDegree:a,width:o=120,type:c,children:u,success:d,size:m=o,steps:f}=e,[h,p]=R(m,"circle"),{strokeWidth:g}=e;void 0===g&&(g=Math.max(3/h*100,6));let x=t.useMemo(()=>a||0===a?a:"dashboard"===c?75:void 0,[a,c]),v=(({percent:e,success:t,successPercent:r})=>{let n=I(T({success:t,successPercent:r}));return[n,I(I(e)-n)]})(e),y="[object Object]"===Object.prototype.toString.call(e.strokeColor),b=(({success:e={},strokeColor:t})=>{let{strokeColor:r}=e;return[r||N.presetPrimaryColors.green,t||null]})({success:d,strokeColor:e.strokeColor}),_=(0,l.default)(`${r}-inner`,{[`${r}-circle-gradient`]:y}),j=t.createElement(E,{steps:f,percent:f?v[1]:v,strokeWidth:g,trailWidth:g,strokeColor:f?b[1]:b,strokeLinecap:i,trailColor:n,prefixCls:r,gapDegree:x,gapPosition:s||"dashboard"===c&&"bottom"||void 0}),w=h<=20,k=t.createElement("div",{className:_,style:{width:h,height:p,fontSize:.15*h+6}},j,!w&&u);return w?t.createElement(O.default,{title:u},k):k};e.i(296059);var D=e.i(694758),$=e.i(915654),A=e.i(183293),F=e.i(246422),L=e.i(838378);let M="--progress-line-stroke-color",B="--progress-percent",U=e=>{let t=e?"100%":"-100%";return new D.Keyframes(`antProgress${e?"RTL":"LTR"}Active`,{"0%":{transform:`translateX(${t}) scaleX(0)`,opacity:.1},"20%":{transform:`translateX(${t}) scaleX(0)`,opacity:.5},to:{transform:"translateX(0) scaleX(1)",opacity:0}})},z=(0,F.genStyleHooks)("Progress",e=>{let t=e.calc(e.marginXXS).div(2).equal(),r=(0,L.mergeToken)(e,{progressStepMarginInlineEnd:t,progressStepMinWidth:t,progressActiveMotionDuration:"2.4s"});return[(e=>{let{componentCls:t,iconCls:r}=e;return{[t]:Object.assign(Object.assign({},(0,A.resetComponent)(e)),{display:"inline-block","&-rtl":{direction:"rtl"},"&-line":{position:"relative",width:"100%",fontSize:e.fontSize},[`${t}-outer`]:{display:"inline-flex",alignItems:"center",width:"100%"},[`${t}-inner`]:{position:"relative",display:"inline-block",width:"100%",flex:1,overflow:"hidden",verticalAlign:"middle",backgroundColor:e.remainingColor,borderRadius:e.lineBorderRadius},[`${t}-inner:not(${t}-circle-gradient)`]:{[`${t}-circle-path`]:{stroke:e.defaultColor}},[`${t}-success-bg, ${t}-bg`]:{position:"relative",background:e.defaultColor,borderRadius:e.lineBorderRadius,transition:`all ${e.motionDurationSlow} ${e.motionEaseInOutCirc}`},[`${t}-layout-bottom`]:{display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",[`${t}-text`]:{width:"max-content",marginInlineStart:0,marginTop:e.marginXXS}},[`${t}-bg`]:{overflow:"hidden","&::after":{content:'""',background:{_multi_value_:!0,value:["inherit",`var(${M})`]},height:"100%",width:`calc(1 / var(${B}) * 100%)`,display:"block"},[`&${t}-bg-inner`]:{minWidth:"max-content","&::after":{content:"none"},[`${t}-text-inner`]:{color:e.colorWhite,[`&${t}-text-bright`]:{color:"rgba(0, 0, 0, 0.45)"}}}},[`${t}-success-bg`]:{position:"absolute",insetBlockStart:0,insetInlineStart:0,backgroundColor:e.colorSuccess},[`${t}-text`]:{display:"inline-block",marginInlineStart:e.marginXS,color:e.colorText,lineHeight:1,width:"2em",whiteSpace:"nowrap",textAlign:"start",verticalAlign:"middle",wordBreak:"normal",[r]:{fontSize:e.fontSize},[`&${t}-text-outer`]:{width:"max-content"},[`&${t}-text-outer${t}-text-start`]:{width:"max-content",marginInlineStart:0,marginInlineEnd:e.marginXS}},[`${t}-text-inner`]:{display:"flex",justifyContent:"center",alignItems:"center",width:"100%",height:"100%",marginInlineStart:0,padding:`0 ${(0,$.unit)(e.paddingXXS)}`,[`&${t}-text-start`]:{justifyContent:"start"},[`&${t}-text-end`]:{justifyContent:"end"}},[`&${t}-status-active`]:{[`${t}-bg::before`]:{position:"absolute",inset:0,backgroundColor:e.colorBgContainer,borderRadius:e.lineBorderRadius,opacity:0,animationName:U(),animationDuration:e.progressActiveMotionDuration,animationTimingFunction:e.motionEaseOutQuint,animationIterationCount:"infinite",content:'""'}},[`&${t}-rtl${t}-status-active`]:{[`${t}-bg::before`]:{animationName:U(!0)}},[`&${t}-status-exception`]:{[`${t}-bg`]:{backgroundColor:e.colorError},[`${t}-text`]:{color:e.colorError}},[`&${t}-status-exception ${t}-inner:not(${t}-circle-gradient)`]:{[`${t}-circle-path`]:{stroke:e.colorError}},[`&${t}-status-success`]:{[`${t}-bg`]:{backgroundColor:e.colorSuccess},[`${t}-text`]:{color:e.colorSuccess}},[`&${t}-status-success ${t}-inner:not(${t}-circle-gradient)`]:{[`${t}-circle-path`]:{stroke:e.colorSuccess}}})}})(r),(e=>{let{componentCls:t,iconCls:r}=e;return{[t]:{[`${t}-circle-trail`]:{stroke:e.remainingColor},[`&${t}-circle ${t}-inner`]:{position:"relative",lineHeight:1,backgroundColor:"transparent"},[`&${t}-circle ${t}-text`]:{position:"absolute",insetBlockStart:"50%",insetInlineStart:0,width:"100%",margin:0,padding:0,color:e.circleTextColor,fontSize:e.circleTextFontSize,lineHeight:1,whiteSpace:"normal",textAlign:"center",transform:"translateY(-50%)",[r]:{fontSize:e.circleIconFontSize}},[`${t}-circle&-status-exception`]:{[`${t}-text`]:{color:e.colorError}},[`${t}-circle&-status-success`]:{[`${t}-text`]:{color:e.colorSuccess}}},[`${t}-inline-circle`]:{lineHeight:1,[`${t}-inner`]:{verticalAlign:"bottom"}}}})(r),(e=>{let{componentCls:t}=e;return{[t]:{[`${t}-steps`]:{display:"inline-block","&-outer":{display:"flex",flexDirection:"row",alignItems:"center"},"&-item":{flexShrink:0,minWidth:e.progressStepMinWidth,marginInlineEnd:e.progressStepMarginInlineEnd,backgroundColor:e.remainingColor,transition:`all ${e.motionDurationSlow}`,"&-active":{backgroundColor:e.defaultColor}}}}}})(r),(e=>{let{componentCls:t,iconCls:r}=e;return{[t]:{[`${t}-small&-line, ${t}-small&-line ${t}-text ${r}`]:{fontSize:e.fontSizeSM}}}})(r)]},e=>({circleTextColor:e.colorText,defaultColor:e.colorInfo,remainingColor:e.colorFillSecondary,lineBorderRadius:100,circleTextFontSize:"1em",circleIconFontSize:`${e.fontSize/e.fontSizeSM}em`}));var V=function(e,t){var r={};for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&0>t.indexOf(n)&&(r[n]=e[n]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols)for(var i=0,n=Object.getOwnPropertySymbols(e);it.indexOf(n[i])&&Object.prototype.propertyIsEnumerable.call(e,n[i])&&(r[n[i]]=e[n[i]]);return r};let H=e=>{let{prefixCls:r,direction:n,percent:i,size:s,strokeWidth:a,strokeColor:o,strokeLinecap:c="round",children:u,trailColor:d=null,percentPosition:m,success:f}=e,{align:h,type:p}=m,g=o&&"string"!=typeof o?((e,t)=>{let{from:r=N.presetPrimaryColors.blue,to:n=N.presetPrimaryColors.blue,direction:i="rtl"===t?"to left":"to right"}=e,s=V(e,["from","to","direction"]);if(0!==Object.keys(s).length){let e,t=(e=[],Object.keys(s).forEach(t=>{let r=Number.parseFloat(t.replace(/%/g,""));Number.isNaN(r)||e.push({key:r,value:s[t]})}),(e=e.sort((e,t)=>e.key-t.key)).map(({key:e,value:t})=>`${t} ${e}%`).join(", ")),r=`linear-gradient(${i}, ${t})`;return{background:r,[M]:r}}let a=`linear-gradient(${i}, ${r}, ${n})`;return{background:a,[M]:a}})(o,n):{[M]:o,background:o},x="square"===c||"butt"===c?0:void 0,[v,y]=R(null!=s?s:[-1,a||("small"===s?6:8)],"line",{strokeWidth:a}),b=Object.assign(Object.assign({width:`${I(i)}%`,height:y,borderRadius:x},g),{[B]:I(i)/100}),_=T(e),j={width:`${I(_)}%`,height:y,borderRadius:x,backgroundColor:null==f?void 0:f.strokeColor},w=t.createElement("div",{className:`${r}-inner`,style:{backgroundColor:d||void 0,borderRadius:x}},t.createElement("div",{className:(0,l.default)(`${r}-bg`,`${r}-bg-${p}`),style:b},"inner"===p&&u),void 0!==_&&t.createElement("div",{className:`${r}-success-bg`,style:j})),k="outer"===p&&"start"===h,C="outer"===p&&"end"===h;return"outer"===p&&"center"===h?t.createElement("div",{className:`${r}-layout-bottom`},w,u):t.createElement("div",{className:`${r}-outer`,style:{width:v<0?"100%":v}},k&&u,w,C&&u)},W=e=>{let{size:r,steps:n,rounding:i=Math.round,percent:s=0,strokeWidth:a=8,strokeColor:o,trailColor:c=null,prefixCls:u,children:d}=e,m=i(s/100*n),[f,h]=R(null!=r?r:["small"===r?2:14,a],"step",{steps:n,strokeWidth:a}),p=f/n,g=Array.from({length:n});for(let e=0;et.indexOf(n)&&(r[n]=e[n]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols)for(var i=0,n=Object.getOwnPropertySymbols(e);it.indexOf(n[i])&&Object.prototype.propertyIsEnumerable.call(e,n[i])&&(r[n[i]]=e[n[i]]);return r};let q=["normal","exception","active","success"],X=t.forwardRef((e,u)=>{let d,{prefixCls:m,className:f,rootClassName:h,steps:p,strokeColor:g,percent:x=0,size:v="default",showInfo:y=!0,type:b="line",status:_,format:j,style:w,percentPosition:k={}}=e,C=K(e,["prefixCls","className","rootClassName","steps","strokeColor","percent","size","showInfo","type","status","format","style","percentPosition"]),{align:S="end",type:E="outer"}=k,O=Array.isArray(g)?g[0]:g,N="string"==typeof g||Array.isArray(g)?g:void 0,D=t.useMemo(()=>{if(O){let e="string"==typeof O?O:Object.values(O)[0];return new r.FastColor(e).isLight()}return!1},[g]),$=t.useMemo(()=>{var t,r;let n=T(e);return Number.parseInt(void 0!==n?null==(t=null!=n?n:0)?void 0:t.toString():null==(r=null!=x?x:0)?void 0:r.toString(),10)},[x,e.success,e.successPercent]),A=t.useMemo(()=>!q.includes(_)&&$>=100?"success":_||"normal",[_,$]),{getPrefixCls:F,direction:L,progress:M}=t.useContext(c.ConfigContext),B=F("progress",m),[U,V,X]=z(B),Q="line"===b,J=Q&&!p,Y=t.useMemo(()=>{let r;if(!y)return null;let o=T(e),c=j||(e=>`${e}%`),u=Q&&D&&"inner"===E;return"inner"===E||j||"exception"!==A&&"success"!==A?r=c(I(x),I(o)):"exception"===A?r=Q?t.createElement(s.default,null):t.createElement(a.default,null):"success"===A&&(r=Q?t.createElement(n.default,null):t.createElement(i.default,null)),t.createElement("span",{className:(0,l.default)(`${B}-text`,{[`${B}-text-bright`]:u,[`${B}-text-${S}`]:J,[`${B}-text-${E}`]:J}),title:"string"==typeof r?r:void 0},r)},[y,x,$,A,b,B,j]);"line"===b?d=p?t.createElement(W,Object.assign({},e,{strokeColor:N,prefixCls:B,steps:"object"==typeof p?p.count:p}),Y):t.createElement(H,Object.assign({},e,{strokeColor:O,prefixCls:B,direction:L,percentPosition:{align:S,type:E}}),Y):("circle"===b||"dashboard"===b)&&(d=t.createElement(P,Object.assign({},e,{strokeColor:O,prefixCls:B,progressStatus:A}),Y));let G=(0,l.default)(B,`${B}-status-${A}`,{[`${B}-${"dashboard"===b&&"circle"||b}`]:"line"!==b,[`${B}-inline-circle`]:"circle"===b&&R(v,"circle")[0]<=20,[`${B}-line`]:J,[`${B}-line-align-${S}`]:J,[`${B}-line-position-${E}`]:J,[`${B}-steps`]:p,[`${B}-show-info`]:y,[`${B}-${v}`]:"string"==typeof v,[`${B}-rtl`]:"rtl"===L},null==M?void 0:M.className,f,h,V,X);return U(t.createElement("div",Object.assign({ref:u,style:Object.assign(Object.assign({},null==M?void 0:M.style),w),className:G,role:"progressbar","aria-valuenow":$,"aria-valuemin":0,"aria-valuemax":100},(0,o.default)(C,["trailColor","strokeWidth","width","gapDegree","gapPosition","strokeLinecap","success","successPercent"])),d))});e.s(["default",0,X],309821)},519756,e=>{"use strict";e.i(247167);var t=e.i(931067),r=e.i(271645);let n={icon:{tag:"svg",attrs:{viewBox:"64 64 896 896",focusable:"false"},children:[{tag:"path",attrs:{d:"M400 317.7h73.9V656c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V317.7H624c6.7 0 10.4-7.7 6.3-12.9L518.3 163a8 8 0 00-12.6 0l-112 141.7c-4.1 5.3-.4 13 6.3 13zM878 626h-60c-4.4 0-8 3.6-8 8v154H214V634c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v198c0 17.7 14.3 32 32 32h684c17.7 0 32-14.3 32-32V634c0-4.4-3.6-8-8-8z"}}]},name:"upload",theme:"outlined"};var i=e.i(9583),s=r.forwardRef(function(e,s){return r.createElement(i.default,(0,t.default)({},e,{ref:s,icon:n}))});e.s(["UploadOutlined",0,s],519756)},233538,e=>{"use strict";e.s(["isDisabledReactIssue7711",0,function(e){let t=e.parentElement,r=null;for(;t&&!(t instanceof HTMLFieldSetElement);)t instanceof HTMLLegendElement&&(r=t),t=t.parentElement;let n=(null==t?void 0:t.getAttribute("disabled"))==="";return!(n&&function(e){if(!e)return!1;let t=e.previousElementSibling;for(;null!==t;){if(t instanceof HTMLLegendElement)return!1;t=t.previousElementSibling}return!0}(r))&&n}])},83733,233137,e=>{"use strict";let t,r;var n,i,s=e.i(247167),a=e.i(271645),l=e.i(544508),o=e.i(746725),c=e.i(835696);void 0!==s.default&&"u">typeof globalThis&&"u">typeof Element&&(null==(n=null==s.default?void 0:s.default.env)?void 0:n.NODE_ENV)==="test"&&void 0===(null==(i=null==Element?void 0:Element.prototype)?void 0:i.getAnimations)&&(Element.prototype.getAnimations=function(){return console.warn(["Headless UI has polyfilled `Element.prototype.getAnimations` for your tests.","Please install a proper polyfill e.g. `jsdom-testing-mocks`, to silence these warnings.","","Example usage:","```js","import { mockAnimationsApi } from 'jsdom-testing-mocks'","mockAnimationsApi()","```"].join(` -`)),[]});var u=((t=u||{})[t.None=0]="None",t[t.Closed=1]="Closed",t[t.Enter=2]="Enter",t[t.Leave=4]="Leave",t);e.s(["transitionDataAttributes",0,function(e){let t={};for(let r in e)!0===e[r]&&(t[`data-${r}`]="");return t},"useTransition",0,function(e,t,r,n){let[i,s]=(0,a.useState)(r),{hasFlag:u,addFlag:d,removeFlag:m}=function(e=0){let[t,r]=(0,a.useState)(e),n=(0,a.useCallback)(e=>r(e),[t]),i=(0,a.useCallback)(e=>r(t=>t|e),[t]),s=(0,a.useCallback)(e=>(t&e)===e,[t]);return{flags:t,setFlag:n,addFlag:i,hasFlag:s,removeFlag:(0,a.useCallback)(e=>r(t=>t&~e),[r]),toggleFlag:(0,a.useCallback)(e=>r(t=>t^e),[r])}}(e&&i?3:0),f=(0,a.useRef)(!1),h=(0,a.useRef)(!1),p=(0,o.useDisposables)();return(0,c.useIsoMorphicEffect)(()=>{var i;if(e){if(r&&s(!0),!t){r&&d(3);return}return null==(i=null==n?void 0:n.start)||i.call(n,r),function(e,{prepare:t,run:r,done:n,inFlight:i}){let s=(0,l.disposables)();return function(e,{inFlight:t,prepare:r}){if(null!=t&&t.current)return r();let n=e.style.transition;e.style.transition="none",r(),e.offsetHeight,e.style.transition=n}(e,{prepare:t,inFlight:i}),s.nextFrame(()=>{r(),s.requestAnimationFrame(()=>{s.add(function(e,t){var r,n;let i=(0,l.disposables)();if(!e)return i.dispose;let s=!1;i.add(()=>{s=!0});let a=null!=(n=null==(r=e.getAnimations)?void 0:r.call(e).filter(e=>e instanceof CSSTransition))?n:[];return 0===a.length?t():Promise.allSettled(a.map(e=>e.finished)).then(()=>{s||t()}),i.dispose}(e,n))})}),s.dispose}(t,{inFlight:f,prepare(){h.current?h.current=!1:h.current=f.current,f.current=!0,h.current||(r?(d(3),m(4)):(d(4),m(2)))},run(){h.current?r?(m(3),d(4)):(m(4),d(3)):r?m(1):d(1)},done(){var e;h.current&&"function"==typeof t.getAnimations&&t.getAnimations().length>0||(f.current=!1,m(7),r||s(!1),null==(e=null==n?void 0:n.end)||e.call(n,r))}})}},[e,r,t,p]),e?[i,{closed:u(1),enter:u(2),leave:u(4),transition:u(2)||u(4)}]:[r,{closed:void 0,enter:void 0,leave:void 0,transition:void 0}]}],83733);let d=(0,a.createContext)(null);d.displayName="OpenClosedContext";var m=((r=m||{})[r.Open=1]="Open",r[r.Closed=2]="Closed",r[r.Closing=4]="Closing",r[r.Opening=8]="Opening",r);e.s(["OpenClosedProvider",0,function({value:e,children:t}){return a.default.createElement(d.Provider,{value:e},t)},"ResetOpenClosedProvider",0,function({children:e}){return a.default.createElement(d.Provider,{value:null},e)},"State",0,m,"useOpenClosed",0,function(){return(0,a.useContext)(d)}],233137)},677667,674175,886148,543086,e=>{"use strict";let t,r;var n,i=e.i(290571),s=e.i(783222),a=e.i(433336),l=e.i(271645),o=e.i(394487),c=e.i(914189),u=e.i(144279),d=e.i(294316),m=e.i(83733);let f=(0,l.createContext)(()=>{});function h({value:e,children:t}){return l.default.createElement(f.Provider,{value:e},t)}e.s(["CloseProvider",0,h],674175);var p=e.i(233137),g=e.i(233538),x=e.i(397701),v=e.i(402155),y=e.i(700020);let b=null!=(n=l.default.startTransition)?n:function(e){e()};var _=e.i(998348),j=((t=j||{})[t.Open=0]="Open",t[t.Closed=1]="Closed",t),w=((r=w||{})[r.ToggleDisclosure=0]="ToggleDisclosure",r[r.CloseDisclosure=1]="CloseDisclosure",r[r.SetButtonId=2]="SetButtonId",r[r.SetPanelId=3]="SetPanelId",r[r.SetButtonElement=4]="SetButtonElement",r[r.SetPanelElement=5]="SetPanelElement",r);let k={0:e=>({...e,disclosureState:(0,x.match)(e.disclosureState,{0:1,1:0})}),1:e=>1===e.disclosureState?e:{...e,disclosureState:1},2:(e,t)=>e.buttonId===t.buttonId?e:{...e,buttonId:t.buttonId},3:(e,t)=>e.panelId===t.panelId?e:{...e,panelId:t.panelId},4:(e,t)=>e.buttonElement===t.element?e:{...e,buttonElement:t.element},5:(e,t)=>e.panelElement===t.element?e:{...e,panelElement:t.element}},C=(0,l.createContext)(null);function S(e){let t=(0,l.useContext)(C);if(null===t){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,S),t}return t}C.displayName="DisclosureContext";let E=(0,l.createContext)(null);E.displayName="DisclosureAPIContext";let O=(0,l.createContext)(null);function N(e,t){return(0,x.match)(t.type,k,e,t)}O.displayName="DisclosurePanelContext";let I=l.Fragment,T=y.RenderFeatures.RenderStrategy|y.RenderFeatures.Static,R=Object.assign((0,y.forwardRefWithAs)(function(e,t){let{defaultOpen:r=!1,...n}=e,i=(0,l.useRef)(null),s=(0,d.useSyncRefs)(t,(0,d.optionalRef)(e=>{i.current=e},void 0===e.as||e.as===l.Fragment)),a=(0,l.useReducer)(N,{disclosureState:+!r,buttonElement:null,panelElement:null,buttonId:null,panelId:null}),[{disclosureState:o,buttonId:u},m]=a,f=(0,c.useEvent)(e=>{m({type:1});let t=(0,v.getOwnerDocument)(i);if(!t||!u)return;let r=e?e instanceof HTMLElement?e:e.current instanceof HTMLElement?e.current:t.getElementById(u):t.getElementById(u);null==r||r.focus()}),g=(0,l.useMemo)(()=>({close:f}),[f]),b=(0,l.useMemo)(()=>({open:0===o,close:f}),[o,f]),_=(0,y.useRender)();return l.default.createElement(C.Provider,{value:a},l.default.createElement(E.Provider,{value:g},l.default.createElement(h,{value:f},l.default.createElement(p.OpenClosedProvider,{value:(0,x.match)(o,{0:p.State.Open,1:p.State.Closed})},_({ourProps:{ref:s},theirProps:n,slot:b,defaultTag:I,name:"Disclosure"})))))}),{Button:(0,y.forwardRefWithAs)(function(e,t){let r=(0,l.useId)(),{id:n=`headlessui-disclosure-button-${r}`,disabled:i=!1,autoFocus:m=!1,...f}=e,[h,p]=S("Disclosure.Button"),x=(0,l.useContext)(O),v=null!==x&&x===h.panelId,b=(0,l.useRef)(null),j=(0,d.useSyncRefs)(b,t,(0,c.useEvent)(e=>{if(!v)return p({type:4,element:e})}));(0,l.useEffect)(()=>{if(!v)return p({type:2,buttonId:n}),()=>{p({type:2,buttonId:null})}},[n,p,v]);let w=(0,c.useEvent)(e=>{var t;if(v){if(1===h.disclosureState)return;switch(e.key){case _.Keys.Space:case _.Keys.Enter:e.preventDefault(),e.stopPropagation(),p({type:0}),null==(t=h.buttonElement)||t.focus()}}else switch(e.key){case _.Keys.Space:case _.Keys.Enter:e.preventDefault(),e.stopPropagation(),p({type:0})}}),k=(0,c.useEvent)(e=>{e.key===_.Keys.Space&&e.preventDefault()}),C=(0,c.useEvent)(e=>{var t;(0,g.isDisabledReactIssue7711)(e.currentTarget)||i||(v?(p({type:0}),null==(t=h.buttonElement)||t.focus()):p({type:0}))}),{isFocusVisible:E,focusProps:N}=(0,s.useFocusRing)({autoFocus:m}),{isHovered:I,hoverProps:T}=(0,a.useHover)({isDisabled:i}),{pressed:R,pressProps:P}=(0,o.useActivePress)({disabled:i}),D=(0,l.useMemo)(()=>({open:0===h.disclosureState,hover:I,active:R,disabled:i,focus:E,autofocus:m}),[h,I,R,E,i,m]),$=(0,u.useResolveButtonType)(e,h.buttonElement),A=v?(0,y.mergeProps)({ref:j,type:$,disabled:i||void 0,autoFocus:m,onKeyDown:w,onClick:C},N,T,P):(0,y.mergeProps)({ref:j,id:n,type:$,"aria-expanded":0===h.disclosureState,"aria-controls":h.panelElement?h.panelId:void 0,disabled:i||void 0,autoFocus:m,onKeyDown:w,onKeyUp:k,onClick:C},N,T,P);return(0,y.useRender)()({ourProps:A,theirProps:f,slot:D,defaultTag:"button",name:"Disclosure.Button"})}),Panel:(0,y.forwardRefWithAs)(function(e,t){let r=(0,l.useId)(),{id:n=`headlessui-disclosure-panel-${r}`,transition:i=!1,...s}=e,[a,o]=S("Disclosure.Panel"),{close:u}=function e(t){let r=(0,l.useContext)(E);if(null===r){let r=Error(`<${t} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(r,e),r}return r}("Disclosure.Panel"),[f,h]=(0,l.useState)(null),g=(0,d.useSyncRefs)(t,(0,c.useEvent)(e=>{b(()=>o({type:5,element:e}))}),h);(0,l.useEffect)(()=>(o({type:3,panelId:n}),()=>{o({type:3,panelId:null})}),[n,o]);let x=(0,p.useOpenClosed)(),[v,_]=(0,m.useTransition)(i,f,null!==x?(x&p.State.Open)===p.State.Open:0===a.disclosureState),j=(0,l.useMemo)(()=>({open:0===a.disclosureState,close:u}),[a.disclosureState,u]),w={ref:g,id:n,...(0,m.transitionDataAttributes)(_)},k=(0,y.useRender)();return l.default.createElement(p.ResetOpenClosedProvider,null,l.default.createElement(O.Provider,{value:a.panelId},k({ourProps:w,theirProps:s,slot:j,defaultTag:"div",features:T,visible:v,name:"Disclosure.Panel"})))})});e.s(["Disclosure",0,R],886148);let P=(0,l.createContext)(void 0);var D=e.i(444755);let $=(0,e.i(673706).makeClassName)("Accordion"),A=(0,l.createContext)({isOpen:!1}),F=l.default.forwardRef((e,t)=>{var r;let{defaultOpen:n=!1,children:s,className:a}=e,o=(0,i.__rest)(e,["defaultOpen","children","className"]),c=null!=(r=(0,l.useContext)(P))?r:(0,D.tremorTwMerge)("rounded-tremor-default border");return l.default.createElement(R,Object.assign({as:"div",ref:t,className:(0,D.tremorTwMerge)($("root"),"overflow-hidden","bg-tremor-background border-tremor-border","dark:bg-dark-tremor-background dark:border-dark-tremor-border",c,a),defaultOpen:n},o),({open:e})=>l.default.createElement(A.Provider,{value:{isOpen:e}},s))});F.displayName="Accordion",e.s(["OpenContext",0,A,"default",0,F],543086),e.s(["Accordion",0,F],677667)},130643,e=>{"use strict";var t=e.i(290571),r=e.i(271645),n=e.i(886148),i=e.i(444755);let s=(0,e.i(673706).makeClassName)("AccordionBody"),a=r.default.forwardRef((e,a)=>{let{children:l,className:o}=e,c=(0,t.__rest)(e,["children","className"]);return r.default.createElement(n.Disclosure.Panel,Object.assign({ref:a,className:(0,i.tremorTwMerge)(s("root"),"w-full text-tremor-default px-4 pb-3","text-tremor-content","dark:text-dark-tremor-content",o)},c),l)});a.displayName="AccordionBody",e.s(["AccordionBody",0,a],130643)},898667,e=>{"use strict";var t=e.i(290571),r=e.i(271645),n=e.i(886148);let i=e=>{var n=(0,t.__rest)(e,[]);return r.default.createElement("svg",Object.assign({xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor"},n),r.default.createElement("path",{d:"M11.9999 10.8284L7.0502 15.7782L5.63599 14.364L11.9999 8L18.3639 14.364L16.9497 15.7782L11.9999 10.8284Z"}))};var s=e.i(543086),a=e.i(444755);let l=(0,e.i(673706).makeClassName)("AccordionHeader"),o=r.default.forwardRef((e,o)=>{let{children:c,className:u}=e,d=(0,t.__rest)(e,["children","className"]),{isOpen:m}=(0,r.useContext)(s.OpenContext);return r.default.createElement(n.Disclosure.Button,Object.assign({ref:o,className:(0,a.tremorTwMerge)(l("root"),"w-full flex items-center justify-between px-4 py-3","text-tremor-content-emphasis","dark:text-dark-tremor-content-emphasis",u)},d),r.default.createElement("div",{className:(0,a.tremorTwMerge)(l("children"),"flex flex-1 text-inherit mr-4")},c),r.default.createElement("div",null,r.default.createElement(i,{className:(0,a.tremorTwMerge)(l("arrowIcon"),"h-5 w-5 -mr-1","text-tremor-content-subtle","dark:text-dark-tremor-content-subtle",m?"transition-all":"transition-all -rotate-180")})))});o.displayName="AccordionHeader",e.s(["AccordionHeader",0,o],898667)},220508,e=>{"use strict";var t=e.i(271645);let r=t.forwardRef(function(e,r){return t.createElement("svg",Object.assign({xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24",strokeWidth:2,stroke:"currentColor","aria-hidden":"true",ref:r},e),t.createElement("path",{strokeLinecap:"round",strokeLinejoin:"round",d:"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"}))});e.s(["CheckCircleIcon",0,r],220508)},503269,214520,601893,694421,140721,942803,35889,722678,e=>{"use strict";var t=e.i(271645),r=e.i(914189);e.s(["useControllable",0,function(e,n,i){let[s,a]=(0,t.useState)(i),l=void 0!==e,o=(0,t.useRef)(l),c=(0,t.useRef)(!1),u=(0,t.useRef)(!1);return!l||o.current||c.current?l||!o.current||u.current||(u.current=!0,o.current=l,console.error("A component is changing from controlled to uncontrolled. This may be caused by the value changing from a defined value to undefined, which should not happen.")):(c.current=!0,o.current=l,console.error("A component is changing from uncontrolled to controlled. This may be caused by the value changing from undefined to a defined value, which should not happen.")),[l?e:s,(0,r.useEvent)(e=>(l||a(e),null==n?void 0:n(e)))]}],503269),e.s(["useDefaultValue",0,function(e){let[r]=(0,t.useState)(e);return r}],214520);let n=(0,t.createContext)(void 0);function i(){return(0,t.useContext)(n)}e.s(["useDisabled",0,i],601893);var s=e.i(174080),a=e.i(746725);function l(e={},t=null,r=[]){for(let[n,i]of Object.entries(e))!function e(t,r,n){if(Array.isArray(n))for(let[i,s]of n.entries())e(t,o(r,i.toString()),s);else n instanceof Date?t.push([r,n.toISOString()]):"boolean"==typeof n?t.push([r,n?"1":"0"]):"string"==typeof n?t.push([r,n]):"number"==typeof n?t.push([r,`${n}`]):null==n?t.push([r,""]):l(n,r,t)}(r,o(t,n),i);return r}function o(e,t){return e?e+"["+t+"]":t}e.s(["attemptSubmit",0,function(e){var t,r;let n=null!=(t=null==e?void 0:e.form)?t:e.closest("form");if(n){for(let t of n.elements)if(t!==e&&("INPUT"===t.tagName&&"submit"===t.type||"BUTTON"===t.tagName&&"submit"===t.type||"INPUT"===t.nodeName&&"image"===t.type))return void t.click();null==(r=n.requestSubmit)||r.call(n)}},"objectToFormEntries",0,l],694421);var c=e.i(700020),u=e.i(2788);let d=(0,t.createContext)(null);function m({children:e}){let r=(0,t.useContext)(d);if(!r)return t.default.createElement(t.default.Fragment,null,e);let{target:n}=r;return n?(0,s.createPortal)(t.default.createElement(t.default.Fragment,null,e),n):null}function f({setForm:e,formId:r}){return(0,t.useEffect)(()=>{if(r){let t=document.getElementById(r);t&&e(t)}},[e,r]),r?null:t.default.createElement(u.Hidden,{features:u.HiddenFeatures.Hidden,as:"input",type:"hidden",hidden:!0,readOnly:!0,ref:t=>{if(!t)return;let r=t.closest("form");r&&e(r)}})}e.s(["FormFields",0,function({data:e,form:r,disabled:n,onReset:i,overrides:s}){let[o,d]=(0,t.useState)(null),h=(0,a.useDisposables)();return(0,t.useEffect)(()=>{if(i&&o)return h.addEventListener(o,"reset",i)},[o,r,i]),t.default.createElement(m,null,t.default.createElement(f,{setForm:d,formId:r}),l(e).map(([e,i])=>t.default.createElement(u.Hidden,{features:u.HiddenFeatures.Hidden,...(0,c.compact)({key:e,as:"input",type:"hidden",hidden:!0,readOnly:!0,form:r,disabled:n,name:e,value:i,...s})})))}],140721);let h=(0,t.createContext)(void 0);function p(){return(0,t.useContext)(h)}e.s(["useProvidedId",0,p],942803);var g=e.i(835696),x=e.i(294316);let v=(0,t.createContext)(null);v.displayName="DescriptionContext";let y=Object.assign((0,c.forwardRefWithAs)(function(e,r){let n=(0,t.useId)(),s=i(),{id:a=`headlessui-description-${n}`,...l}=e,o=function e(){let r=(0,t.useContext)(v);if(null===r){let t=Error("You used a component, but it is not inside a relevant parent.");throw Error.captureStackTrace&&Error.captureStackTrace(t,e),t}return r}(),u=(0,x.useSyncRefs)(r);(0,g.useIsoMorphicEffect)(()=>o.register(a),[a,o.register]);let d=s||!1,m=(0,t.useMemo)(()=>({...o.slot,disabled:d}),[o.slot,d]),f={ref:u,...o.props,id:a};return(0,c.useRender)()({ourProps:f,theirProps:l,slot:m,defaultTag:"p",name:o.name||"Description"})}),{});e.s(["Description",0,y,"useDescribedBy",0,function(){var e,r;return null!=(r=null==(e=(0,t.useContext)(v))?void 0:e.value)?r:void 0},"useDescriptions",0,function(){let[e,n]=(0,t.useState)([]);return[e.length>0?e.join(" "):void 0,(0,t.useMemo)(()=>function(e){let i=(0,r.useEvent)(e=>(n(t=>[...t,e]),()=>n(t=>{let r=t.slice(),n=r.indexOf(e);return -1!==n&&r.splice(n,1),r}))),s=(0,t.useMemo)(()=>({register:i,slot:e.slot,name:e.name,props:e.props,value:e.value}),[i,e.slot,e.name,e.props,e.value]);return t.default.createElement(v.Provider,{value:s},e.children)},[n])]}],35889);let b=(0,t.createContext)(null);function _(e){var r,n,i;let s=null!=(n=null==(r=(0,t.useContext)(b))?void 0:r.value)?n:void 0;return(null!=(i=null==e?void 0:e.length)?i:0)>0?[s,...e].filter(Boolean).join(" "):s}b.displayName="LabelContext";let j=Object.assign((0,c.forwardRefWithAs)(function(e,n){var s;let a=(0,t.useId)(),l=function e(){let r=(0,t.useContext)(b);if(null===r){let t=Error("You used a