mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_fix_ai_gateway_image_build
This commit is contained in:
commit
754bdc0a25
1389 changed files with 153303 additions and 16244 deletions
|
|
@ -575,7 +575,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-v -x \
|
||||
-v \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
-k \"langfuse\""
|
||||
|
|
@ -630,7 +630,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-v -x \
|
||||
-v \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
|
|
@ -737,7 +737,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-v -x \
|
||||
-v \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
|
|
@ -782,7 +782,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-v -x \
|
||||
-v \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
-k \"assistants\""
|
||||
|
|
@ -909,7 +909,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x -s \
|
||||
-vv -s \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5"
|
||||
|
|
@ -999,7 +999,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x -s \
|
||||
-vv -s \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
|
|
@ -1054,7 +1054,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-v -x \
|
||||
-v \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
-n 8 \
|
||||
|
|
@ -1090,7 +1090,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x \
|
||||
-vv \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
|
|
@ -1134,7 +1134,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x \
|
||||
-vv \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
|
|
@ -1178,7 +1178,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x -s \
|
||||
-vv -s \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
|
|
@ -1222,7 +1222,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x -s \
|
||||
-vv -s \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
|
|
@ -1267,7 +1267,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x \
|
||||
-vv \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
|
|
@ -1312,7 +1312,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-v -x \
|
||||
-v \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 \
|
||||
-n 4"
|
||||
|
|
@ -1391,7 +1391,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x -s \
|
||||
-vv -s \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5"
|
||||
|
|
@ -1444,7 +1444,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x -s \
|
||||
-vv -s \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5 -n 2 \
|
||||
|
|
@ -1705,7 +1705,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-v -x \
|
||||
-v \
|
||||
--junitxml=test-results/junit-2.xml \
|
||||
--durations=5"
|
||||
no_output_timeout: 15m
|
||||
|
|
@ -1794,7 +1794,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-s -v -x \
|
||||
-s -v \
|
||||
--junitxml=test-results/junit.xml \
|
||||
-n 4 \
|
||||
--durations=5"
|
||||
|
|
@ -2012,7 +2012,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-v -x \
|
||||
-v \
|
||||
--junitxml=test-results/junit-2.xml \
|
||||
--durations=5"
|
||||
no_output_timeout: 15m
|
||||
|
|
@ -2092,7 +2092,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x \
|
||||
-vv \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5"
|
||||
no_output_timeout: 15m
|
||||
|
|
@ -2195,7 +2195,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x \
|
||||
-vv \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5"
|
||||
no_output_timeout: 15m
|
||||
|
|
@ -2266,7 +2266,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x \
|
||||
-vv \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5"
|
||||
no_output_timeout: 15m
|
||||
|
|
@ -2350,7 +2350,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x \
|
||||
-vv \
|
||||
--junitxml=test-results/junit-2.xml \
|
||||
--durations=5"
|
||||
no_output_timeout: 15m
|
||||
|
|
@ -2446,7 +2446,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-v -x \
|
||||
-v \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5"
|
||||
no_output_timeout: 15m
|
||||
|
|
@ -2516,7 +2516,7 @@ jobs:
|
|||
echo "$TEST_FILES" | circleci tests run \
|
||||
--verbose \
|
||||
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \
|
||||
-vv -x -s \
|
||||
-vv -s \
|
||||
--junitxml=test-results/junit.xml \
|
||||
--durations=5"
|
||||
no_output_timeout: 15m
|
||||
|
|
|
|||
14
.github/actions/setup-uv-with-retries/action.yml
vendored
14
.github/actions/setup-uv-with-retries/action.yml
vendored
|
|
@ -1,11 +1,7 @@
|
|||
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.
|
||||
Install uv via astral-sh/setup-uv, retrying the full setup step so manifest
|
||||
resolution and binary downloads get fresh attempts after transient failures.
|
||||
|
||||
inputs:
|
||||
version:
|
||||
|
|
@ -18,7 +14,7 @@ runs:
|
|||
- name: Set up uv (attempt 1)
|
||||
id: attempt-1
|
||||
continue-on-error: true
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
|
||||
|
|
@ -31,7 +27,7 @@ runs:
|
|||
id: attempt-2
|
||||
if: steps.attempt-1.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
|
||||
|
|
@ -42,6 +38,6 @@ runs:
|
|||
|
||||
- name: Set up uv (attempt 3)
|
||||
if: steps.attempt-2.outcome == 'failure'
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
|
|
|
|||
10
.github/ci-coverage-allowlist.yml
vendored
10
.github/ci-coverage-allowlist.yml
vendored
|
|
@ -4,6 +4,11 @@ description: >-
|
|||
by a job nor listed here, so every entry below is a decision on the record.
|
||||
|
||||
test_paths:
|
||||
- reason: >-
|
||||
The Rust/Python parity harness is run manually through its local CLI. Recorded replay,
|
||||
fixture generation, and harness checks are intentionally outside pull request CI
|
||||
paths:
|
||||
- tests/rust-python-harness
|
||||
- reason: >-
|
||||
What is left of the caching suite in tests/local_testing that runs nowhere. Every job that
|
||||
globs that directory either deselects it (local_testing_part1 and part2 carry `-k "... and
|
||||
|
|
@ -74,6 +79,11 @@ test_paths:
|
|||
- tests/load_tests/test_otel_load_test.py
|
||||
- tests/load_tests/test_vertex_embeddings_load_test.py
|
||||
- tests/load_tests/test_vertex_load_tests.py
|
||||
- reason: >-
|
||||
Env-gated saturation benchmark requires a live proxy and provider credentials, so it is run
|
||||
locally rather than in pull-request jobs
|
||||
paths:
|
||||
- tests/load_tests/test_granian_admission_saturation.py
|
||||
- reason: >-
|
||||
A local-only agent rig: test_a2a_completion_bridge.py needs a LangGraph server on
|
||||
localhost:2024 and test_a2a.py drives a live A2A endpoint, so neither can run in a
|
||||
|
|
|
|||
31
.github/workflows/_test-unit-base.yml
vendored
31
.github/workflows/_test-unit-base.yml
vendored
|
|
@ -57,9 +57,15 @@ permissions:
|
|||
|
||||
jobs:
|
||||
run:
|
||||
name: Run tests
|
||||
name: ${{ matrix.python-version == '3.12' && 'Run tests' || format('Run tests (Python {0})', matrix.python-version) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: ${{ inputs.job-timeout-minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
|
@ -82,7 +88,7 @@ jobs:
|
|||
timeout-minutes: 3
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Set up uv
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
|
|
@ -96,12 +102,10 @@ jobs:
|
|||
timeout-minutes: 5
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.cache/uv
|
||||
.venv
|
||||
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: ${{ runner.os }}-uv-downloads-py${{ matrix.python-version }}-${{ hashFiles('uv.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-uv-
|
||||
${{ runner.os }}-uv-downloads-py${{ matrix.python-version }}-
|
||||
|
||||
- name: Cache the Rust build
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
|
|
@ -112,7 +116,8 @@ jobs:
|
|||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 8
|
||||
run: |
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra mongodb
|
||||
uv run --no-sync python -c 'import os, sys; print(sys.version); assert f"{sys.version_info.major}.{sys.version_info.minor}" == os.environ["UV_PYTHON"]'
|
||||
|
||||
- name: Cache Prisma binaries
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
|
|
@ -134,13 +139,7 @@ jobs:
|
|||
WORKERS: ${{ inputs.workers }}
|
||||
RERUNS: ${{ inputs.reruns }}
|
||||
DIST: ${{ inputs.dist }}
|
||||
# coverage.py's sys.monitoring backend (PEP 669), the cheapest core it has.
|
||||
# It is only the default from Python 3.14, and these shards run 3.12, so it
|
||||
# has to be asked for. Coverage refuses it when branch measurement is on
|
||||
# (`branch_right_left` needs > 3.14.0a5) and falls back to the slow core with
|
||||
# a `no-sysmon` warning, so turning on `branch = true` here means giving this
|
||||
# back until the runners move to 3.14.
|
||||
COVERAGE_CORE: sysmon
|
||||
COVERAGE_CORE: ${{ contains(fromJSON('["3.10", "3.11"]'), matrix.python-version) && 'ctrace' || 'sysmon' }}
|
||||
run: |
|
||||
if [ "${WORKERS}" = "0" ]; then
|
||||
uv run --no-sync pytest ${TEST_PATH:?} \
|
||||
|
|
@ -167,7 +166,7 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Save coverage report
|
||||
if: always() && steps.changes.outputs.decision != 'skip'
|
||||
if: always() && matrix.python-version == '3.12' && 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 }}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
issues: write # PR comments use the issues API
|
||||
pull-requests: read # Current-head validation rejects stale workflow runs
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Link release wheel report on PR
|
||||
|
|
|
|||
24
.github/workflows/test-litellm-ui-build.yml
vendored
24
.github/workflows/test-litellm-ui-build.yml
vendored
|
|
@ -19,9 +19,6 @@ jobs:
|
|||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui/litellm-dashboard
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
|
@ -35,18 +32,11 @@ jobs:
|
|||
with:
|
||||
category: ui
|
||||
|
||||
- name: Setup Node.js
|
||||
# Built through the image stage rather than the checkout, because the
|
||||
# stage copies ui/litellm-dashboard/ alone: an import reaching above the
|
||||
# dashboard root resolves in a checkout and fails in every image we ship.
|
||||
# Dockerfile, docker/Dockerfile.non_root and ui/Dockerfile share this
|
||||
# stage verbatim, so building one covers all three.
|
||||
- name: Build the dashboard as the shipped images build it
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version-file: ui/litellm-dashboard/.nvmrc
|
||||
cache: "npm"
|
||||
cache-dependency-path: ui/litellm-dashboard/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: npm run build
|
||||
run: docker build --target ui-builder -f Dockerfile .
|
||||
|
|
|
|||
31
.github/workflows/test-terraform-modules.yml
vendored
31
.github/workflows/test-terraform-modules.yml
vendored
|
|
@ -4,6 +4,7 @@ on:
|
|||
push:
|
||||
paths:
|
||||
- "terraform/litellm/aws/**"
|
||||
- "terraform/litellm/gcp/**"
|
||||
- ".github/workflows/test-terraform-modules.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
|
|
@ -13,6 +14,7 @@ on:
|
|||
- "litellm_**"
|
||||
paths:
|
||||
- "terraform/litellm/aws/**"
|
||||
- "terraform/litellm/gcp/**"
|
||||
- ".github/workflows/test-terraform-modules.yml"
|
||||
|
||||
permissions:
|
||||
|
|
@ -52,3 +54,32 @@ jobs:
|
|||
# Plan-only, mock_provider-backed: no AWS credentials, no API calls.
|
||||
- name: test
|
||||
run: terraform test
|
||||
|
||||
gcp-module:
|
||||
name: fmt, validate, test (gcp)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: terraform/litellm/gcp
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
with:
|
||||
terraform_version: 1.13.3
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: fmt
|
||||
run: terraform fmt -recursive -check -diff
|
||||
|
||||
- name: init
|
||||
run: terraform init -backend=false -input=false
|
||||
|
||||
- name: validate
|
||||
run: terraform validate
|
||||
|
||||
- name: test
|
||||
run: terraform test
|
||||
|
|
|
|||
2
.github/workflows/test-unit.yml
vendored
2
.github/workflows/test-unit.yml
vendored
|
|
@ -96,7 +96,6 @@ jobs:
|
|||
- shard: misc
|
||||
artifact-name: misc
|
||||
test-path: >-
|
||||
tests/sdk_function_trace
|
||||
tests/test_litellm/batches
|
||||
tests/test_litellm/secret_managers
|
||||
tests/test_litellm/a2a_protocol
|
||||
|
|
@ -151,6 +150,7 @@ jobs:
|
|||
tests/test_litellm/proxy/google_endpoints
|
||||
tests/test_litellm/proxy/openai_files_endpoint
|
||||
tests/test_litellm/proxy/batches_endpoints
|
||||
tests/test_litellm/proxy/container_endpoints
|
||||
tests/test_litellm/proxy/fine_tuning_endpoints
|
||||
tests/test_litellm/proxy/vector_store_files_endpoints
|
||||
tests/test_litellm/proxy/video_endpoints
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"reportAny": {
|
||||
"limit": 14074
|
||||
"limit": 13429
|
||||
},
|
||||
"reportArgumentType": {
|
||||
"limit": 2215
|
||||
"limit": 2198
|
||||
},
|
||||
"reportAssignmentType": {
|
||||
"limit": 319
|
||||
|
|
@ -18,13 +18,13 @@
|
|||
"limit": 40
|
||||
},
|
||||
"reportDeprecated": {
|
||||
"limit": 211
|
||||
"limit": 209
|
||||
},
|
||||
"reportDuplicateImport": {
|
||||
"limit": 19
|
||||
},
|
||||
"reportExplicitAny": {
|
||||
"limit": 4124
|
||||
"limit": 3369
|
||||
},
|
||||
"reportFunctionMemberAccess": {
|
||||
"limit": 7
|
||||
|
|
@ -42,22 +42,22 @@
|
|||
"limit": 12
|
||||
},
|
||||
"reportIndexIssue": {
|
||||
"limit": 25
|
||||
"limit": 24
|
||||
},
|
||||
"reportInvalidTypeForm": {
|
||||
"limit": 34
|
||||
"limit": 30
|
||||
},
|
||||
"reportInvalidTypeVarUse": {
|
||||
"limit": 2
|
||||
"limit": 1
|
||||
},
|
||||
"reportMatchNotExhaustive": {
|
||||
"limit": 0
|
||||
},
|
||||
"reportMissingParameterType": {
|
||||
"limit": 5601
|
||||
"limit": 5570
|
||||
},
|
||||
"reportMissingTypeArgument": {
|
||||
"limit": 15288
|
||||
"limit": 15281
|
||||
},
|
||||
"reportMissingTypeStubs": {
|
||||
"limit": 40
|
||||
|
|
@ -90,31 +90,31 @@
|
|||
"limit": 8
|
||||
},
|
||||
"reportReturnType": {
|
||||
"limit": 181
|
||||
"limit": 180
|
||||
},
|
||||
"reportTypedDictNotRequiredAccess": {
|
||||
"limit": 24
|
||||
"limit": 22
|
||||
},
|
||||
"reportUndefinedVariable": {
|
||||
"limit": 0
|
||||
},
|
||||
"reportUnknownArgumentType": {
|
||||
"limit": 44362
|
||||
"limit": 44358
|
||||
},
|
||||
"reportUnknownLambdaType": {
|
||||
"limit": 109
|
||||
},
|
||||
"reportUnknownMemberType": {
|
||||
"limit": 38324
|
||||
"limit": 38271
|
||||
},
|
||||
"reportUnknownParameterType": {
|
||||
"limit": 19625
|
||||
"limit": 19584
|
||||
},
|
||||
"reportUnknownVariableType": {
|
||||
"limit": 29861
|
||||
"limit": 29814
|
||||
},
|
||||
"reportUnnecessaryCast": {
|
||||
"limit": 111
|
||||
"limit": 110
|
||||
},
|
||||
"reportUnnecessaryComparison": {
|
||||
"limit": 687
|
||||
|
|
@ -123,7 +123,7 @@
|
|||
"limit": 4
|
||||
},
|
||||
"reportUnnecessaryIsInstance": {
|
||||
"limit": 819
|
||||
"limit": 816
|
||||
},
|
||||
"reportUntypedBaseClass": {
|
||||
"limit": 0
|
||||
|
|
|
|||
|
|
@ -55,7 +55,10 @@ RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/
|
|||
|
||||
ENV PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries
|
||||
|
||||
COPY docker/prod_entrypoint.sh /app/docker/prod_entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /app/docker/prod_entrypoint.sh && chmod +x /app/docker/prod_entrypoint.sh
|
||||
|
||||
EXPOSE 4000/tcp
|
||||
|
||||
ENTRYPOINT ["litellm"]
|
||||
ENTRYPOINT ["/app/docker/prod_entrypoint.sh"]
|
||||
CMD ["--port", "4000"]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
#!/bin/sh
|
||||
|
||||
if [ "$USE_DDTRACE" = "true" ]; then
|
||||
export DD_TRACE_OPENAI_ENABLED="False"
|
||||
exec ddtrace-run "$@"
|
||||
fi
|
||||
case "$USE_DDTRACE" in
|
||||
[Tt][Rr][Uu][Ee])
|
||||
export DD_TRACE_OPENAI_ENABLED="False"
|
||||
exec ddtrace-run "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
exec "$@"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
#!/bin/sh
|
||||
|
||||
if [ "$USE_DDTRACE" = "true" ]; then
|
||||
export DD_TRACE_OPENAI_ENABLED="False"
|
||||
exec ddtrace-run litellm "$@"
|
||||
else
|
||||
exec litellm "$@"
|
||||
fi
|
||||
case "$USE_DDTRACE" in
|
||||
[Tt][Rr][Uu][Ee])
|
||||
export DD_TRACE_OPENAI_ENABLED="False"
|
||||
exec ddtrace-run litellm "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
exec litellm "$@"
|
||||
|
|
|
|||
|
|
@ -11,17 +11,35 @@ import sys
|
|||
sys.path.insert(
|
||||
0, os.path.abspath("../..")
|
||||
) # Adds the parent directory to the system path
|
||||
import functools
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal, Optional
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.integrations.custom_guardrail import (
|
||||
CustomGuardrail,
|
||||
log_guardrail_information,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails._content_utils import walk_user_text
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
GUARDRAIL_NAME = "hide_secrets"
|
||||
|
||||
GUARDRAIL_PROVIDER = "hide-secrets"
|
||||
|
||||
# Per-invocation tally of redacted secrets by detect-secrets plugin type; None
|
||||
# means the guardrail did not run, so _process_response records nothing.
|
||||
_masked_entity_count: ContextVar[Optional[dict]] = ContextVar(
|
||||
"hide_secrets_masked_entity_count", default=None
|
||||
)
|
||||
|
||||
_custom_plugins_path = "file://" + os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "secrets_plugins"
|
||||
)
|
||||
|
|
@ -415,13 +433,17 @@ _default_detect_secrets_config = {
|
|||
"name": "ZendeskSecretKeyDetector",
|
||||
"path": _custom_plugins_path + "/zendesk_secret_key.py",
|
||||
},
|
||||
{"name": "Base64HighEntropyString", "limit": 3.0},
|
||||
{"name": "Base64HighEntropyString", "limit": 4.5},
|
||||
{"name": "HexHighEntropyString", "limit": 3.0},
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class _ENTERPRISE_SecretDetection(CustomGuardrail):
|
||||
# Keeps proxied traffic on async_pre_call_hook (the unified apply_guardrail
|
||||
# path skips should_run_check and never sees data["prompt"]).
|
||||
use_native_lifecycle_hooks: ClassVar[bool] = True
|
||||
|
||||
def __init__(self, detect_secrets_config: Optional[dict] = None, **kwargs):
|
||||
self.user_defined_detect_secrets_config = detect_secrets_config
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -444,16 +466,39 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
|
|||
|
||||
os.remove(temp_file.name)
|
||||
|
||||
detected_secrets = []
|
||||
for file in secrets.files:
|
||||
for found_secret in secrets[file]:
|
||||
if found_secret.secret_value is None:
|
||||
continue
|
||||
detected_secrets.append(
|
||||
{"type": found_secret.type, "value": found_secret.secret_value}
|
||||
)
|
||||
return [
|
||||
{"type": found_secret.type, "value": found_secret.secret_value}
|
||||
for file in sorted(secrets.files)
|
||||
for found_secret in sorted(
|
||||
secrets[file],
|
||||
key=lambda secret: (
|
||||
-len(secret.secret_value or ""),
|
||||
secret.type,
|
||||
secret.secret_value or "",
|
||||
),
|
||||
)
|
||||
if found_secret.secret_value is not None
|
||||
]
|
||||
|
||||
return detected_secrets
|
||||
def redact_text(self, text: str, source: str = "message") -> str:
|
||||
"""Replace every detected secret in ``text`` with ``[REDACTED]`` and
|
||||
tally the detected types into the per-invocation masked-entity count."""
|
||||
detected_secrets = self.scan_message_for_secrets(text)
|
||||
if not detected_secrets:
|
||||
return text
|
||||
counts = _masked_entity_count.get()
|
||||
if counts is not None:
|
||||
for secret in detected_secrets:
|
||||
counts[secret["type"]] = counts.get(secret["type"], 0) + 1
|
||||
secret_types = [secret["type"] for secret in detected_secrets]
|
||||
verbose_proxy_logger.warning(
|
||||
f"Detected and redacted secrets in {source}: {secret_types}"
|
||||
)
|
||||
return functools.reduce(
|
||||
lambda redacted, secret: redacted.replace(secret["value"], "[REDACTED]"),
|
||||
detected_secrets,
|
||||
text,
|
||||
)
|
||||
|
||||
async def should_run_check(self, user_api_key_dict: UserAPIKeyAuth) -> bool:
|
||||
if user_api_key_dict.permissions is not None:
|
||||
|
|
@ -463,7 +508,45 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
|
|||
|
||||
return True
|
||||
|
||||
@log_guardrail_information
|
||||
async def apply_guardrail(
|
||||
self,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
request_data: dict,
|
||||
input_type: Literal["request", "response"],
|
||||
logging_obj: Optional["LiteLLMLoggingObj"] = None,
|
||||
) -> GenericGuardrailAPIInputs:
|
||||
"""Unified-interface entrypoint, used by /guardrails/apply_guardrail
|
||||
(the UI test playground). Proxied traffic keeps using
|
||||
``async_pre_call_hook``, see ``use_native_lifecycle_hooks``."""
|
||||
texts = inputs.get("texts")
|
||||
if not texts or not any(texts):
|
||||
return inputs
|
||||
_masked_entity_count.set({})
|
||||
return {**inputs, "texts": [self.redact_text(text) for text in texts]}
|
||||
|
||||
def _redact_prompt(self, data: dict) -> int:
|
||||
"""Redact ``data["prompt"]`` (the text-completion shape, which
|
||||
``walk_user_text`` does not cover) and return how many non-empty
|
||||
strings were inspected."""
|
||||
prompt = data.get("prompt")
|
||||
if isinstance(prompt, str):
|
||||
if not prompt:
|
||||
return 0
|
||||
data["prompt"] = self.redact_text(prompt, source="prompt")
|
||||
return 1
|
||||
if isinstance(prompt, list):
|
||||
data["prompt"] = [ # mutable-ok: data["prompt"] is a list on the wire
|
||||
self.redact_text(item, source="prompt")
|
||||
if isinstance(item, str) and item
|
||||
else item
|
||||
for item in prompt
|
||||
]
|
||||
return sum(1 for item in prompt if isinstance(item, str) and item)
|
||||
return 0
|
||||
|
||||
#### CALL HOOKS - proxy only ####
|
||||
@log_guardrail_information
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
|
|
@ -471,53 +554,84 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
|
|||
data: dict,
|
||||
call_type: str, # "completion", "embeddings", "image_generation", "moderation"
|
||||
):
|
||||
_masked_entity_count.set(None)
|
||||
if await self.should_run_check(user_api_key_dict) is False:
|
||||
return
|
||||
|
||||
_masked_entity_count.set({})
|
||||
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
def _redact_message_text(text: str) -> str:
|
||||
detected_secrets = self.scan_message_for_secrets(text)
|
||||
for secret in detected_secrets:
|
||||
text = text.replace(secret["value"], "[REDACTED]")
|
||||
if detected_secrets:
|
||||
secret_types = [secret["type"] for secret in detected_secrets]
|
||||
verbose_proxy_logger.warning(
|
||||
f"Detected and redacted secrets in message: {secret_types}"
|
||||
)
|
||||
return text
|
||||
inspected = walk_user_text(data, self.redact_text) + self._redact_prompt(data)
|
||||
|
||||
walk_user_text(data, _redact_message_text)
|
||||
if inspected == 0:
|
||||
# Image-only, empty-text, and unsupported payloads inspected
|
||||
# nothing, so recording "allow" would count a run that never
|
||||
# looked at any content.
|
||||
_masked_entity_count.set(None)
|
||||
|
||||
if "prompt" in data:
|
||||
if isinstance(data["prompt"], str):
|
||||
detected_secrets = self.scan_message_for_secrets(data["prompt"])
|
||||
for secret in detected_secrets:
|
||||
data["prompt"] = data["prompt"].replace(
|
||||
secret["value"], "[REDACTED]"
|
||||
)
|
||||
if len(detected_secrets) > 0:
|
||||
secret_types = [secret["type"] for secret in detected_secrets]
|
||||
verbose_proxy_logger.warning(
|
||||
f"Detected and redacted secrets in prompt: {secret_types}"
|
||||
)
|
||||
elif isinstance(data["prompt"], list):
|
||||
# Index back into the list — assigning to ``item`` would only
|
||||
# rebind the loop variable and leave ``data["prompt"]``
|
||||
# carrying the unredacted secret.
|
||||
for idx, item in enumerate(data["prompt"]):
|
||||
if isinstance(item, str):
|
||||
detected_secrets = self.scan_message_for_secrets(item)
|
||||
for secret in detected_secrets:
|
||||
item = item.replace(secret["value"], "[REDACTED]")
|
||||
data["prompt"][idx] = item
|
||||
if len(detected_secrets) > 0:
|
||||
secret_types = [
|
||||
secret["type"] for secret in detected_secrets
|
||||
]
|
||||
verbose_proxy_logger.warning(
|
||||
f"Detected and redacted secrets in prompt: {secret_types}"
|
||||
)
|
||||
|
||||
# ``data["input"]`` (Responses API and embeddings/moderation) is
|
||||
# already covered by ``walk_user_text`` above.
|
||||
return
|
||||
|
||||
def _process_response(
|
||||
self,
|
||||
response: Optional[dict],
|
||||
request_data: dict,
|
||||
start_time: Optional[float] = None,
|
||||
end_time: Optional[float] = None,
|
||||
duration: Optional[float] = None,
|
||||
event_type: Optional[GuardrailEventHooks] = None,
|
||||
original_inputs: Optional[dict] = None,
|
||||
):
|
||||
"""Record allow/mask plus the masked-entity tally for a completed run.
|
||||
|
||||
Records nothing when the guardrail inspected nothing (opted-out key,
|
||||
empty inputs) or when the instance has no guardrail_name (legacy
|
||||
``litellm_settings.callbacks`` deployments, which predate guardrail
|
||||
telemetry and stay without it).
|
||||
"""
|
||||
counts = _masked_entity_count.get()
|
||||
_masked_entity_count.set(None)
|
||||
if counts is None or self.guardrail_name is None:
|
||||
return response
|
||||
self.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_json_response="mask" if counts else "allow",
|
||||
request_data=request_data,
|
||||
guardrail_status="success",
|
||||
duration=duration,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type=event_type,
|
||||
guardrail_provider=GUARDRAIL_PROVIDER,
|
||||
masked_entity_count=counts,
|
||||
)
|
||||
return response
|
||||
|
||||
def _process_error(
|
||||
self,
|
||||
e: Exception,
|
||||
request_data: dict,
|
||||
start_time: Optional[float] = None,
|
||||
end_time: Optional[float] = None,
|
||||
duration: Optional[float] = None,
|
||||
event_type: Optional[GuardrailEventHooks] = None,
|
||||
):
|
||||
"""Label the failed run with this guardrail's provider so error rows
|
||||
group with the successful ones in the monitor. Nameless legacy
|
||||
instances record nothing, matching ``_process_response``."""
|
||||
_masked_entity_count.set(None)
|
||||
if self.guardrail_name is None:
|
||||
raise e
|
||||
self.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_json_response=e,
|
||||
request_data=request_data,
|
||||
guardrail_status=(
|
||||
"guardrail_intervened"
|
||||
if self._is_guardrail_intervention(e)
|
||||
else "guardrail_failed_to_respond"
|
||||
),
|
||||
duration=duration,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type=event_type,
|
||||
guardrail_provider=GUARDRAIL_PROVIDER,
|
||||
)
|
||||
raise e
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ This plugin searches for OpenAI API Keys.
|
|||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Generator
|
||||
|
||||
from detect_secrets.plugins.base import RegexBasedDetector
|
||||
|
||||
|
|
@ -16,4 +17,16 @@ class OpenAIApiKeyDetector(RegexBasedDetector):
|
|||
|
||||
@property
|
||||
def denylist(self) -> list[re.Pattern]:
|
||||
return [re.compile(r"""(sk-[a-zA-Z0-9]{5,})""")]
|
||||
return [
|
||||
re.compile(
|
||||
r"((?:(?<![a-zA-Z0-9])|(?<=%[0-9A-Fa-f]{2}))"
|
||||
r"sk[-_]"
|
||||
r"[a-zA-Z0-9_-]{5,}"
|
||||
r"(?![a-zA-Z0-9_-]))"
|
||||
)
|
||||
]
|
||||
|
||||
def analyze_string(self, string: str) -> Generator[str, None, None]:
|
||||
# the digit check lives outside the regex: a lookahead re-scans the token
|
||||
# from every `sk` inside it, which is quadratic on `-sk-sk-sk-...` input
|
||||
yield from (match for match in super().analyze_string(string) if re.search(r"[0-9]", match))
|
||||
|
|
|
|||
|
|
@ -48,6 +48,18 @@ def _build_json_field_or_condition(json_key: str, value: str) -> dict[str, objec
|
|||
}
|
||||
|
||||
|
||||
def _build_search_condition(search: str) -> dict[str, object]:
|
||||
"""Match a row whose id, changed_by, object_id, or changed_by_api_key equals the search value."""
|
||||
return {
|
||||
"OR": (
|
||||
{"id": search},
|
||||
{"changed_by": search},
|
||||
{"object_id": search},
|
||||
{"changed_by_api_key": search},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/audit",
|
||||
tags=["Audit Logging"],
|
||||
|
|
@ -83,6 +95,10 @@ async def get_audit_logs(
|
|||
None,
|
||||
description="Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only)",
|
||||
),
|
||||
search: str | None = Query(
|
||||
None,
|
||||
description="Match a row whose id, object_id, changed_by, or changed_by_api_key equals this value",
|
||||
),
|
||||
# Sorting parameters
|
||||
sort_by: str | None = Query(
|
||||
None,
|
||||
|
|
@ -118,6 +134,11 @@ async def get_audit_logs(
|
|||
*([_build_json_field_or_condition("token", object_key_hash)] if object_key_hash else []),
|
||||
]
|
||||
|
||||
and_conditions: Final[tuple[dict[str, object], ...]] = (
|
||||
*json_field_conditions,
|
||||
*((_build_search_condition(search),) if search else ()),
|
||||
)
|
||||
|
||||
# Build filter conditions
|
||||
where_conditions: Final[dict[str, object]] = {
|
||||
**({"changed_by": changed_by} if changed_by else {}),
|
||||
|
|
@ -126,14 +147,14 @@ async def get_audit_logs(
|
|||
**({"table_name": table_name} if table_name else {}),
|
||||
**({"object_id": object_id} if object_id else {}),
|
||||
**({"updated_at": date_filter} if start_date or end_date else {}),
|
||||
**({"AND": json_field_conditions} if json_field_conditions else {}),
|
||||
**({"AND": and_conditions} if and_conditions else {}),
|
||||
}
|
||||
|
||||
order_by: Final[dict[str, str]] = (
|
||||
{sort_by: sort_order} if sort_by and isinstance(sort_by, str) else {"updated_at": sort_order}
|
||||
)
|
||||
|
||||
audit_log_table: Final[TableActions["prisma_models.LiteLLM_AuditLog"]] = AuditLogRepository(prisma_client).table
|
||||
audit_log_table: Final[TableActions[prisma_models.LiteLLM_AuditLog]] = AuditLogRepository(prisma_client).table
|
||||
|
||||
# Get paginated results
|
||||
audit_logs: Final = await audit_log_table.find_many(
|
||||
|
|
@ -195,7 +216,7 @@ async def get_audit_log_by_id(
|
|||
detail={"message": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
audit_log_table: Final[TableActions["prisma_models.LiteLLM_AuditLog"]] = AuditLogRepository(prisma_client).table
|
||||
audit_log_table: Final[TableActions[prisma_models.LiteLLM_AuditLog]] = AuditLogRepository(prisma_client).table
|
||||
|
||||
# Get the audit log by ID
|
||||
audit_log: Final = await audit_log_table.find_unique(where={"id": id})
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ class CheckBatchCost:
|
|||
metadata: dict[str, object] = {
|
||||
"user_api_key_user_id": job.created_by,
|
||||
"user_api_key": api_key,
|
||||
"user_api_key_hash": api_key,
|
||||
"user_api_key_team_id": team_id,
|
||||
**(await self._get_user_info(batch_id, job.created_by)),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,14 +46,17 @@ from litellm.proxy._types import (
|
|||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
BATCH_CREATE_HIDDEN_PARAM,
|
||||
FILE_LIST_CONTINUATION_CHUNK_SIZE,
|
||||
MAX_FILE_LIST_LIMIT,
|
||||
_is_base64_encoded_unified_file_id,
|
||||
apply_unified_file_ids,
|
||||
decode_model_from_file_id,
|
||||
ensure_batch_response_managed_file_ids,
|
||||
get_batch_id_from_unified_batch_id,
|
||||
get_content_type_from_file_object,
|
||||
get_model_id_from_unified_batch_id,
|
||||
get_original_file_id,
|
||||
map_raw_file_ids_to_unified,
|
||||
normalize_mime_type_for_provider,
|
||||
resolve_managed_output_file_model_name,
|
||||
|
|
@ -427,6 +430,103 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
detail=f"Object not found: {unified_object_id}",
|
||||
)
|
||||
|
||||
async def enforce_batch_object_access(
|
||||
self, object_id: str, user_api_key_dict: UserAPIKeyAuth
|
||||
) -> None:
|
||||
"""Deny access to a provider-format batch id owned by another caller.
|
||||
|
||||
Ids with no ownership row (batches created before ownership tracking,
|
||||
or directly on the provider account) stay accessible so pass-through
|
||||
reads keep working.
|
||||
"""
|
||||
if self.prisma_client is None:
|
||||
return
|
||||
managed_object = (
|
||||
await self.prisma_client.db.litellm_managedobjecttable.find_first(
|
||||
where={"OR": [{"unified_object_id": object_id}, {"model_object_id": object_id}]}
|
||||
)
|
||||
)
|
||||
if managed_object is None:
|
||||
return
|
||||
if not can_access_resource(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
created_by=managed_object.created_by,
|
||||
resource_team_id=managed_object.team_id,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"User {user_api_key_dict.user_id} does not have access to the object {object_id}",
|
||||
)
|
||||
|
||||
async def enforce_provider_file_access(
|
||||
self, file_id: str, user_api_key_dict: UserAPIKeyAuth
|
||||
) -> None:
|
||||
"""Deny access to a provider-format file id owned by another caller.
|
||||
|
||||
Ownership rows for provider-format ids are written when a managed
|
||||
batch's output/error files are first synced; ids with no row stay
|
||||
accessible so pass-through reads keep working.
|
||||
"""
|
||||
if self.prisma_client is None:
|
||||
return
|
||||
managed_file = (
|
||||
await self.prisma_client.db.litellm_managedfiletable.find_first(
|
||||
where={"OR": [{"unified_file_id": file_id}, {"flat_model_file_ids": {"has": file_id}}]}
|
||||
)
|
||||
)
|
||||
if managed_file is None:
|
||||
return
|
||||
if not can_access_resource(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
created_by=managed_file.created_by,
|
||||
resource_team_id=managed_file.team_id,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"User {user_api_key_dict.user_id} does not have access to the file {file_id}",
|
||||
)
|
||||
|
||||
async def store_batch_output_file_ownership(
|
||||
self, response: LiteLLMBatch, litellm_parent_otel_span: Optional[Span]
|
||||
) -> None:
|
||||
"""Record ownership rows for a batch's provider-format output/error
|
||||
file ids, inherited from the owning batch row (never the caller), so
|
||||
file reads can be isolation-checked."""
|
||||
provider_file_ids = tuple(
|
||||
file_id
|
||||
for file_id in (
|
||||
getattr(response, "output_file_id", None),
|
||||
getattr(response, "error_file_id", None),
|
||||
)
|
||||
if file_id and not _is_base64_encoded_unified_file_id(file_id)
|
||||
)
|
||||
if not provider_file_ids:
|
||||
return
|
||||
if self.prisma_client is None:
|
||||
return
|
||||
batch_row = (
|
||||
await self.prisma_client.db.litellm_managedobjecttable.find_first(
|
||||
where={"unified_object_id": response.id}
|
||||
)
|
||||
)
|
||||
if batch_row is None or (
|
||||
batch_row.created_by is None and batch_row.team_id is None
|
||||
):
|
||||
return
|
||||
owner_identity = UserAPIKeyAuth(
|
||||
user_id=batch_row.created_by, team_id=batch_row.team_id
|
||||
)
|
||||
for file_id in provider_file_ids:
|
||||
model_name = decode_model_from_file_id(file_id)
|
||||
raw_file_id = get_original_file_id(file_id)
|
||||
await self.store_unified_file_id(
|
||||
file_id=file_id,
|
||||
file_object=None,
|
||||
litellm_parent_otel_span=litellm_parent_otel_span,
|
||||
model_mappings={model_name: raw_file_id} if model_name else {},
|
||||
user_api_key_dict=owner_identity,
|
||||
)
|
||||
|
||||
async def list_user_batches(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
|
|
@ -613,6 +713,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
status_code=403,
|
||||
detail=f"User {user_api_key_dict.user_id} does not have access to the file {retrieve_file_id}",
|
||||
)
|
||||
if retrieve_file_id:
|
||||
await self.enforce_provider_file_access(
|
||||
retrieve_file_id, user_api_key_dict
|
||||
)
|
||||
return False
|
||||
|
||||
async def check_file_ids_access(self, file_ids: List[str], user_api_key_dict: UserAPIKeyAuth) -> None:
|
||||
|
|
@ -765,6 +869,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
)
|
||||
data["model"] = potential_model_id
|
||||
data[accessor_key] = get_batch_id_from_unified_batch_id(potential_llm_object_id)
|
||||
elif retrieve_object_id and accessor_key == "batch_id":
|
||||
await self.enforce_batch_object_access(retrieve_object_id, user_api_key_dict)
|
||||
elif call_type == CallTypes.acreate_fine_tuning_job.value:
|
||||
input_file_id = cast(Optional[str], data.get("training_file"))
|
||||
if input_file_id:
|
||||
|
|
@ -1216,7 +1322,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
## Check if unified_file_id is in the response
|
||||
unified_file_id = response._hidden_params.get("unified_file_id") # managed file id
|
||||
unified_batch_id = response._hidden_params.get("unified_batch_id") # managed batch id
|
||||
is_batch_create: Final = unified_file_id is not None
|
||||
is_batch_create: Final = response._hidden_params.get(BATCH_CREATE_HIDDEN_PARAM) is True
|
||||
model_id = cast(Optional[str], response._hidden_params.get("model_id"))
|
||||
model_name = cast(Optional[str], response._hidden_params.get("model_name"))
|
||||
|
||||
|
|
@ -1297,12 +1403,15 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
request_tags=request_tags_from_metadata(request_metadata if isinstance(request_metadata, dict) else {}),
|
||||
persist_attribution=is_batch_create,
|
||||
create_if_missing=is_batch_create,
|
||||
)
|
||||
if not is_batch_create:
|
||||
await self.store_batch_output_file_ownership(
|
||||
response=response,
|
||||
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
|
||||
)
|
||||
|
||||
# Only record batch creation metric on actual create (not retrieve/cancel).
|
||||
# unified_file_id in _hidden_params is only set by the create_batch endpoint.
|
||||
original_unified_file_id = response._hidden_params.get("unified_file_id")
|
||||
if original_unified_file_id:
|
||||
if is_batch_create:
|
||||
prom_logger = self._get_prometheus_logger()
|
||||
if prom_logger:
|
||||
batch_provider = ""
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from litellm.llms.base_llm.managed_resources.utils import (
|
|||
is_base64_encoded_unified_id,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.utils import LLMResponseTypes
|
||||
from litellm.types.vector_stores import (
|
||||
VectorStoreCreateOptionalRequestParams,
|
||||
VectorStoreCreateResponse,
|
||||
|
|
@ -24,6 +25,7 @@ from litellm.types.vector_stores import (
|
|||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import Span as _Span
|
||||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache
|
||||
from litellm.proxy.utils import PrismaClient as _PrismaClient
|
||||
|
||||
|
|
@ -156,7 +158,7 @@ class _PROXY_LiteLLMManagedVectorStores(
|
|||
|
||||
# Create vector store for each model
|
||||
# Convert TypedDict to Dict[str, Any] for base class compatibility
|
||||
request_data_dict: Dict[str, Any] = dict(create_request)
|
||||
request_data_dict: Dict[str, object] = dict(create_request)
|
||||
responses = await self.create_resource_for_each_model(
|
||||
llm_router=llm_router,
|
||||
request_data=request_data_dict,
|
||||
|
|
@ -209,7 +211,7 @@ class _PROXY_LiteLLMManagedVectorStores(
|
|||
limit: Optional[int] = None,
|
||||
after: Optional[str] = None,
|
||||
order: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> Dict[str, object]:
|
||||
"""
|
||||
List vector stores created by a user.
|
||||
|
||||
|
|
@ -301,7 +303,7 @@ class _PROXY_LiteLLMManagedVectorStores(
|
|||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: Any,
|
||||
cache: "DualCache",
|
||||
data: Dict,
|
||||
call_type: str,
|
||||
) -> Union[Exception, str, Dict, None]:
|
||||
|
|
@ -403,8 +405,8 @@ class _PROXY_LiteLLMManagedVectorStores(
|
|||
self,
|
||||
data: Dict,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
response: Any,
|
||||
) -> Any:
|
||||
response: LLMResponseTypes,
|
||||
) -> LLMResponseTypes:
|
||||
"""
|
||||
Post-call hook to transform responses.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.63"
|
||||
version = "0.1.65"
|
||||
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.63"
|
||||
version = "0.1.65"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
|
|
@ -428,3 +428,16 @@ envFrom:
|
|||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
ingress-nginx's admission webhook rejects a dot in an Exact or Prefix path
|
||||
(strict-validate-path-type) and serves ImplementationSpecific as a plain
|
||||
prefix location, so a dotted path takes that type there.
|
||||
*/}}
|
||||
{{- define "litellm.ingress.pathType" -}}
|
||||
{{- if and (eq .controller "nginx") (contains "." .path) -}}
|
||||
ImplementationSpecific
|
||||
{{- else -}}
|
||||
{{- .pathType -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
{{- $gatewayPort := .Values.gateway.service.port -}}
|
||||
{{- $backendPort := .Values.backend.service.port -}}
|
||||
{{- $uiPort := .Values.ui.service.port -}}
|
||||
{{- $controller := .Values.ingress.controller | default "alb" -}}
|
||||
{{- if not (has $controller (list "alb" "nginx")) }}
|
||||
{{- fail (printf "ingress.controller: unknown controller %q, expected one of alb, nginx" $controller) }}
|
||||
{{- end }}
|
||||
{{/*
|
||||
Backends addressable from ingress.extraPaths, keyed by the `service` field.
|
||||
*/}}
|
||||
|
|
@ -27,10 +31,11 @@
|
|||
/litellm-asset-prefix, so without /*.txt they fall to the backend catch-all
|
||||
→ 404 → client-side navigation never settles and the login flow spins in an
|
||||
infinite redirect loop (/ ⇄ /ui/login). ui/nginx.conf already serves *.txt
|
||||
from the export; the rule only routes the request to it. Needs an ingress
|
||||
controller whose ImplementationSpecific path is a wildcard pattern
|
||||
(AWS ALB: `*` = 0+ chars); this chart targets the AWS Load Balancer
|
||||
Controller.
|
||||
from the export; the rule only routes the request to it. It needs an
|
||||
ingress controller whose ImplementationSpecific path is a wildcard pattern
|
||||
(AWS ALB: `*` = 0+ chars), so it is rendered for ingress.controller=alb
|
||||
only: ingress-nginx serves ImplementationSpecific as a literal prefix
|
||||
location, where /*.txt can never match.
|
||||
*/}}
|
||||
{{- $uiPaths := list
|
||||
(dict "path" "/" "pathType" "Exact")
|
||||
|
|
@ -38,8 +43,10 @@
|
|||
(dict "path" "/litellm-asset-prefix" "pathType" "Prefix")
|
||||
(dict "path" "/_next" "pathType" "Prefix")
|
||||
(dict "path" "/ui" "pathType" "Prefix")
|
||||
(dict "path" "/*.txt" "pathType" "ImplementationSpecific")
|
||||
-}}
|
||||
{{- if eq $controller "alb" }}
|
||||
{{- $uiPaths = append $uiPaths (dict "path" "/*.txt" "pathType" "ImplementationSpecific") }}
|
||||
{{- end }}
|
||||
{{/*
|
||||
Gateway data-plane prefixes — must mirror gateway/routes/allowlist.py.
|
||||
Versioned paths are listed explicitly to avoid routing management routes
|
||||
|
|
@ -83,12 +90,6 @@
|
|||
adding to it.
|
||||
*/}}
|
||||
{{- $builtinPathKeys := list "/test|Exact" "/|Prefix" -}}
|
||||
{{- range $uiPaths }}
|
||||
{{- $builtinPathKeys = append $builtinPathKeys (printf "%s|%s" .path .pathType) }}
|
||||
{{- end }}
|
||||
{{- range $gatewayPrefixes }}
|
||||
{{- $builtinPathKeys = append $builtinPathKeys (printf "%s|Prefix" .) }}
|
||||
{{- end }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
|
|
@ -115,8 +116,10 @@ spec:
|
|||
paths:
|
||||
# --- UI (Next.js static export) ---
|
||||
{{- range $uiPaths }}
|
||||
{{- $pathType := include "litellm.ingress.pathType" (dict "controller" $controller "path" .path "pathType" .pathType) }}
|
||||
{{- $builtinPathKeys = append $builtinPathKeys (printf "%s|%s" .path $pathType) }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
pathType: {{ $pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ $uiName }}
|
||||
|
|
@ -134,8 +137,10 @@ spec:
|
|||
port:
|
||||
number: {{ $gatewayPort }}
|
||||
{{- range $gatewayPrefixes }}
|
||||
{{- $pathType := include "litellm.ingress.pathType" (dict "controller" $controller "path" . "pathType" "Prefix") }}
|
||||
{{- $builtinPathKeys = append $builtinPathKeys (printf "%s|%s" . $pathType) }}
|
||||
- path: {{ . }}
|
||||
pathType: Prefix
|
||||
pathType: {{ $pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ $gatewayName }}
|
||||
|
|
@ -147,10 +152,11 @@ spec:
|
|||
Rendered after every built-in path so an entry can never take
|
||||
precedence over a default, and before the backend catch-all.
|
||||
Position only decides the match on controllers that honour manifest
|
||||
order: the AWS Load Balancer Controller this chart targets sorts
|
||||
Exact paths first and Prefix paths longest-first, but keeps
|
||||
order: the AWS Load Balancer Controller (ingress.controller=alb)
|
||||
sorts Exact paths first and Prefix paths longest-first, but keeps
|
||||
ImplementationSpecific paths in manifest order, which is what the
|
||||
/*.txt rule above already depends on.
|
||||
/*.txt rule above already depends on. ingress-nginx ignores order
|
||||
and serves the longest matching location.
|
||||
*/}}
|
||||
{{- range $idx, $extra := .Values.ingress.extraPaths }}
|
||||
{{- if not (kindIs "map" $extra) }}
|
||||
|
|
@ -164,10 +170,11 @@ spec:
|
|||
{{- if not $target }}
|
||||
{{- fail (printf "ingress.extraPaths[%d] (path %s): unknown service %q, expected one of backend, gateway, ui" $idx $extra.path $service) }}
|
||||
{{- end }}
|
||||
{{- $pathType := $extra.pathType | default "Prefix" }}
|
||||
{{- if not (has $pathType (list "Prefix" "Exact" "ImplementationSpecific")) }}
|
||||
{{- fail (printf "ingress.extraPaths[%d] (path %s): unknown pathType %q, expected one of Exact, ImplementationSpecific, Prefix" $idx $extra.path $pathType) }}
|
||||
{{- $requestedPathType := $extra.pathType | default "Prefix" }}
|
||||
{{- if not (has $requestedPathType (list "Prefix" "Exact" "ImplementationSpecific")) }}
|
||||
{{- fail (printf "ingress.extraPaths[%d] (path %s): unknown pathType %q, expected one of Exact, ImplementationSpecific, Prefix" $idx $extra.path $requestedPathType) }}
|
||||
{{- end }}
|
||||
{{- $pathType := include "litellm.ingress.pathType" (dict "controller" $controller "path" $extra.path "pathType" $requestedPathType) }}
|
||||
{{- if eq $extra.path "/" }}
|
||||
{{- fail (printf "ingress.extraPaths[%d]: path / is already routed in both directions, Exact to ui and Prefix to backend, so no pathType leaves a request for an entry here to capture" $idx) }}
|
||||
{{- end }}
|
||||
|
|
|
|||
|
|
@ -77,4 +77,16 @@ spec:
|
|||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.migrationJob.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.migrationJob.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.migrationJob.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
|
|
|||
205
helm/litellm/tests/ingress_controller_tests.yaml
Normal file
205
helm/litellm/tests/ingress_controller_tests.yaml
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
suite: test ingress.controller
|
||||
templates:
|
||||
- ingress.yaml
|
||||
values:
|
||||
- ./values/required.yaml
|
||||
tests:
|
||||
- it: keeps the AWS Load Balancer Controller path types by default
|
||||
set:
|
||||
ingress.enabled: true
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /favicon.ico
|
||||
pathType: Exact
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-ui
|
||||
port:
|
||||
number: 3000
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /eu.assemblyai
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-gateway
|
||||
port:
|
||||
number: 4000
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /*.txt
|
||||
pathType: ImplementationSpecific
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-ui
|
||||
port:
|
||||
number: 3000
|
||||
|
||||
- it: renders no dotted Exact or Prefix path for ingress-nginx, whose admission webhook rejects them
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.controller: nginx
|
||||
asserts:
|
||||
- notMatchRegexRaw:
|
||||
pattern: 'path: /\S*\.\S*\n\s+pathType: (Exact|Prefix)\n'
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /favicon.ico
|
||||
pathType: ImplementationSpecific
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-ui
|
||||
port:
|
||||
number: 3000
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /eu.assemblyai
|
||||
pathType: ImplementationSpecific
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-gateway
|
||||
port:
|
||||
number: 4000
|
||||
|
||||
- it: drops the /*.txt wildcard for ingress-nginx and keeps every other route as is
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.controller: nginx
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /*.txt
|
||||
any: true
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /ui
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-ui
|
||||
port:
|
||||
number: 3000
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /test
|
||||
pathType: Exact
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-gateway
|
||||
port:
|
||||
number: 4000
|
||||
- equal:
|
||||
path: spec.rules[0].http.paths[-1]
|
||||
value:
|
||||
path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-backend
|
||||
port:
|
||||
number: 4001
|
||||
|
||||
- it: rejects an extraPaths entry that repeats a built-in path at the pathType ingress-nginx renders it with
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.controller: nginx
|
||||
ingress.extraPaths:
|
||||
- path: /favicon.ico
|
||||
service: ui
|
||||
pathType: ImplementationSpecific
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: "ingress.extraPaths[0]: path /favicon.ico with pathType ImplementationSpecific is already routed by this chart, and a duplicate would take it over rather than add to it"
|
||||
|
||||
- it: rejects a controller it has no path types for
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.controller: traefik
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: 'ingress.controller: unknown controller "traefik", expected one of alb, nginx'
|
||||
|
||||
- it: rejects an extraPaths entry that repeats a built-in path once ingress-nginx normalizes its pathType
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.controller: nginx
|
||||
ingress.extraPaths:
|
||||
- path: /favicon.ico
|
||||
service: ui
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: "ingress.extraPaths[0]: path /favicon.ico with pathType ImplementationSpecific is already routed by this chart, and a duplicate would take it over rather than add to it"
|
||||
|
||||
- it: renders a dotted extraPaths entry as ImplementationSpecific for ingress-nginx
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.controller: nginx
|
||||
ingress.extraPaths:
|
||||
- path: /eu.assemblyai.custom
|
||||
service: gateway
|
||||
- path: /robots.txt
|
||||
service: ui
|
||||
pathType: Exact
|
||||
asserts:
|
||||
- notMatchRegexRaw:
|
||||
pattern: 'path: "?/\S*\.\S*"?\n\s+pathType: (Exact|Prefix)\n'
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /eu.assemblyai.custom
|
||||
pathType: ImplementationSpecific
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-gateway
|
||||
port:
|
||||
number: 4000
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /robots.txt
|
||||
pathType: ImplementationSpecific
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-ui
|
||||
port:
|
||||
number: 3000
|
||||
|
||||
- it: keeps the requested pathType of a dotted extraPaths entry for the AWS Load Balancer Controller
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.extraPaths:
|
||||
- path: /eu.assemblyai.custom
|
||||
service: gateway
|
||||
- path: /robots.txt
|
||||
service: ui
|
||||
pathType: Exact
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /eu.assemblyai.custom
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-gateway
|
||||
port:
|
||||
number: 4000
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /robots.txt
|
||||
pathType: Exact
|
||||
backend:
|
||||
service:
|
||||
name: RELEASE-NAME-litellm-ui
|
||||
port:
|
||||
number: 3000
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
suite: test migrations Job ServiceAccount resolution and pod hardening
|
||||
suite: test migrations Job ServiceAccount resolution, pod hardening, and scheduling
|
||||
templates:
|
||||
- migrations-job.yaml
|
||||
values:
|
||||
|
|
@ -188,3 +188,69 @@ tests:
|
|||
asserts:
|
||||
- notExists:
|
||||
path: spec.activeDeadlineSeconds
|
||||
|
||||
- it: renders no scheduling fields by default
|
||||
asserts:
|
||||
- isNull:
|
||||
path: spec.template.spec.nodeSelector
|
||||
- isNull:
|
||||
path: spec.template.spec.tolerations
|
||||
- isNull:
|
||||
path: spec.template.spec.affinity
|
||||
|
||||
- it: renders nodeSelector, tolerations, and affinity from the migrationJob values
|
||||
set:
|
||||
migrationJob.nodeSelector:
|
||||
intent: no-csi-nodes
|
||||
migrationJob.tolerations:
|
||||
- key: intent
|
||||
operator: Equal
|
||||
value: no-csi-nodes
|
||||
effect: NoSchedule
|
||||
migrationJob.affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: intent
|
||||
operator: In
|
||||
values:
|
||||
- no-csi-nodes
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.spec.nodeSelector
|
||||
value:
|
||||
intent: no-csi-nodes
|
||||
- equal:
|
||||
path: spec.template.spec.tolerations
|
||||
value:
|
||||
- key: intent
|
||||
operator: Equal
|
||||
value: no-csi-nodes
|
||||
effect: NoSchedule
|
||||
- equal:
|
||||
path: spec.template.spec.affinity
|
||||
value:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: intent
|
||||
operator: In
|
||||
values:
|
||||
- no-csi-nodes
|
||||
|
||||
- it: does not inherit the gateway's scheduling values
|
||||
set:
|
||||
gateway.nodeSelector:
|
||||
intent: no-csi-nodes
|
||||
gateway.tolerations:
|
||||
- key: intent
|
||||
operator: Equal
|
||||
value: no-csi-nodes
|
||||
effect: NoSchedule
|
||||
asserts:
|
||||
- isNull:
|
||||
path: spec.template.spec.nodeSelector
|
||||
- isNull:
|
||||
path: spec.template.spec.tolerations
|
||||
|
|
|
|||
|
|
@ -10,6 +10,18 @@ imagePullSecrets: []
|
|||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
# Which ingress controller serves this Ingress. Controllers disagree on the
|
||||
# pathTypes they accept, so this picks the pathType of the dotted paths, the
|
||||
# built-in ones and any dotted extraPaths entry alike:
|
||||
# alb AWS Load Balancer Controller (default): Exact and Prefix paths plus
|
||||
# the /*.txt wildcard that routes the UI's RSC payloads.
|
||||
# nginx ingress-nginx: its admission webhook rejects a dot in an Exact or
|
||||
# Prefix path (strict-validate-path-type, on by default from v1.12.0
|
||||
# until v1.12.6 / v1.13.2 allowed dots again), so /favicon.ico and
|
||||
# /eu.assemblyai render as ImplementationSpecific, which nginx serves
|
||||
# as a plain prefix location. /*.txt is dropped: nginx has no
|
||||
# wildcard pathType, so that rule could never match there.
|
||||
controller: alb
|
||||
annotations: {}
|
||||
host: "" # optional; if set, becomes the rule's host
|
||||
tls: []
|
||||
|
|
@ -26,7 +38,8 @@ ingress:
|
|||
#
|
||||
# path required; the HTTP path to route
|
||||
# service which component serves it: gateway (default), backend, or ui
|
||||
# pathType Prefix (default), Exact, or ImplementationSpecific
|
||||
# pathType Prefix (default), Exact, or ImplementationSpecific; a dotted
|
||||
# path renders as ImplementationSpecific when controller is nginx
|
||||
#
|
||||
# The target component only answers paths its own route allowlist keeps, so
|
||||
# a path here still has to be one that component serves.
|
||||
|
|
@ -139,6 +152,13 @@ migrationJob:
|
|||
# the writable scratch space a read-only root filesystem needs.
|
||||
volumes: []
|
||||
volumeMounts: []
|
||||
# Scheduling for the Job pod, same shape as gateway.nodeSelector /
|
||||
# gateway.tolerations / gateway.affinity. The Job does not inherit the other
|
||||
# components' scheduling values: a migration usually needs a larger node
|
||||
# than the gateway, so pin it here explicitly.
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
image:
|
||||
repository: ghcr.io/berriai/litellm-migrations
|
||||
tag: "" # defaults to .Chart.AppVersion
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_DailyGuardrailUsageUnits" ADD COLUMN IF NOT EXISTS "cost" DOUBLE PRECISION;
|
||||
ALTER TABLE "LiteLLM_DailyGuardrailUsageUnits" ADD COLUMN IF NOT EXISTS "untracked_units" BIGINT NOT NULL DEFAULT 0;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "LiteLLM_ShadowEvalJob" ADD COLUMN IF NOT EXISTS "models" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
|
|
@ -18,10 +18,15 @@ recoverable one.
|
|||
constant: it grows with the number of pending migrations, so a fresh database
|
||||
that has to replay every migration this package ships overruns a per-command
|
||||
budget sized for the short bookkeeping commands, on a laptop as much as on a
|
||||
slow CI runner. The Python ``prisma`` wrapper spawns Node and the schema engine
|
||||
as separate children, so killing the wrapper on timeout leaves them running:
|
||||
the retry then contends with that orphan for Prisma's advisory lock and cannot
|
||||
finish any sooner. Migrate deploy therefore runs under its own budget.
|
||||
slow CI runner. Migrate deploy therefore runs under its own budget.
|
||||
|
||||
The Python ``prisma`` wrapper spawns Node, which spawns the Rust schema
|
||||
engine, so killing only the wrapper on timeout leaves the engine running with
|
||||
no parent: it keeps mutating the database after the proxy has given up, holds
|
||||
Prisma's advisory lock so every retry and every later boot queues behind it,
|
||||
and dies mid-migration once its pipes close, leaving a half-applied ledger row.
|
||||
Every Prisma command therefore runs in a process group of its own, and a
|
||||
timeout kills the whole group.
|
||||
|
||||
All three budgets are overridable so an operator can widen them without a
|
||||
release: ``LITELLM_PRISMA_BOOTSTRAP_TIMEOUT`` for the toolchain install,
|
||||
|
|
@ -35,10 +40,12 @@ the deploy override says otherwise.
|
|||
import math
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import IO, Optional, Union
|
||||
|
||||
from litellm_proxy_extras._logging import logger
|
||||
|
||||
|
|
@ -167,6 +174,49 @@ def heal_incomplete_nodeenv_cache() -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _kill_process_group(process: "subprocess.Popen[str]") -> None:
|
||||
if os.name == "nt":
|
||||
process.kill()
|
||||
return
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
|
||||
def run_prisma(
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
timeout: float,
|
||||
env: Mapping[str, str],
|
||||
stdout: Union[IO[str], int, None] = subprocess.PIPE,
|
||||
stderr: Optional[int] = subprocess.PIPE,
|
||||
) -> "subprocess.CompletedProcess[str]":
|
||||
"""Run one Prisma CLI command in its own process group, bounded by ``timeout``.
|
||||
|
||||
Raises ``subprocess.TimeoutExpired`` once the budget is spent, after killing
|
||||
the command together with every process it spawned, and
|
||||
``subprocess.CalledProcessError`` on a non-zero exit. Output is captured as
|
||||
text unless ``stdout``/``stderr`` say otherwise.
|
||||
"""
|
||||
with subprocess.Popen(
|
||||
argv,
|
||||
env=env,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
) as process:
|
||||
try:
|
||||
out, err = process.communicate(timeout=timeout)
|
||||
except BaseException:
|
||||
_kill_process_group(process)
|
||||
raise
|
||||
if process.returncode:
|
||||
raise subprocess.CalledProcessError(process.returncode, process.args, out, err)
|
||||
return subprocess.CompletedProcess(process.args, process.returncode, out, err)
|
||||
|
||||
|
||||
def ensure_prisma_toolchain(
|
||||
prisma_command: str, prisma_env: dict[str, str]
|
||||
) -> ToolchainBootstrap:
|
||||
|
|
@ -179,14 +229,7 @@ def ensure_prisma_toolchain(
|
|||
timeout = prisma_bootstrap_timeout()
|
||||
logger.info("Preparing the Prisma CLI toolchain (timeout %ss)", timeout)
|
||||
try:
|
||||
subprocess.run(
|
||||
[prisma_command, BOOTSTRAP_ARG],
|
||||
timeout=timeout,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=prisma_env,
|
||||
)
|
||||
run_prisma([prisma_command, BOOTSTRAP_ARG], timeout=timeout, env=prisma_env)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"Preparing the Prisma CLI toolchain timed out after %ss. Raise %s "
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import tempfile
|
|||
from pathlib import Path
|
||||
|
||||
from litellm_proxy_extras._logging import logger
|
||||
from litellm_proxy_extras.prisma_toolchain import prisma_command_timeout
|
||||
from litellm_proxy_extras.prisma_toolchain import prisma_command_timeout, run_prisma
|
||||
|
||||
REPLICA_IDENTITY_FULL_ENV_VAR = "LITELLM_SET_REPLICA_IDENTITY_FULL"
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ def apply_replica_identity_full(
|
|||
with tempfile.TemporaryDirectory(prefix="litellm_replica_identity_") as tmp_dir:
|
||||
sql_path = Path(tmp_dir) / "replica_identity_full.sql"
|
||||
sql_path.write_text(REPLICA_IDENTITY_FULL_SQL)
|
||||
subprocess.run(
|
||||
run_prisma(
|
||||
[
|
||||
prisma_command,
|
||||
"db",
|
||||
|
|
@ -77,9 +77,6 @@ def apply_replica_identity_full(
|
|||
schema_path,
|
||||
],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=prisma_env,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ datasource client {
|
|||
|
||||
generator client {
|
||||
provider = "prisma-client-py"
|
||||
recursive_type_depth = -1
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x", "linux-musl", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
|
|
@ -1123,6 +1124,8 @@ model LiteLLM_DailyGuardrailUsageUnits {
|
|||
api_key String // hashed virtual key; empty string when unknown
|
||||
usage_unit String // provider counter name, e.g. Bedrock's contentPolicyUnits
|
||||
units BigInt @default(0)
|
||||
cost Float? // USD for the priced share of units; null only on rows written before this column existed
|
||||
untracked_units BigInt @default(0) // units recorded with no known price, the share cost leaves out
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
|
|
@ -1533,6 +1536,7 @@ model LiteLLM_ShadowEvalJob {
|
|||
target_id String // hashed virtual key, team_id, or user_id whose traffic this leg shadows
|
||||
router_name String // first (often only) auto-router under evaluation; router_names is the full set
|
||||
router_names String[] @default([]) // all routers this job runs as shadow arms; empty on legacy rows, whose set is (router_name)
|
||||
models String[] @default([]) // model groups the sampled traffic is narrowed to; empty samples every model
|
||||
direction String @default("forward") // forward | reverse
|
||||
baseline_model String? // reverse only: the fixed model the router is judged against
|
||||
judge_model String
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import shutil
|
|||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from litellm_proxy_extras import prisma_toolchain
|
||||
from litellm_proxy_extras._logging import logger
|
||||
from litellm_proxy_extras.replica_identity import (
|
||||
REPLICA_IDENTITY_FULL_ENV_VAR,
|
||||
|
|
@ -45,6 +47,38 @@ _MIGRATION_TS_RE = re.compile(r"^(\d{14})_")
|
|||
|
||||
_MIGRATION_DEADLOCK_MARKER = "deadlock detected"
|
||||
|
||||
MAX_MIGRATE_DEPLOY_ATTEMPTS = 4
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _MigrateAttemptBudget:
|
||||
"""Retries left, and the recoveries already run.
|
||||
|
||||
A recovery that lands something new costs nothing, so a database full of
|
||||
objects `prisma db push` created works through them one per pass. Anything
|
||||
that made no progress spends an attempt, so a stuck run still gives up.
|
||||
"""
|
||||
|
||||
attempts_left: int
|
||||
recoveries: frozenset[str] = frozenset()
|
||||
|
||||
@property
|
||||
def exhausted(self) -> bool:
|
||||
return self.attempts_left <= 0
|
||||
|
||||
@property
|
||||
def attempt_number(self) -> int:
|
||||
return MAX_MIGRATE_DEPLOY_ATTEMPTS - self.attempts_left + 1
|
||||
|
||||
def spend(self) -> "_MigrateAttemptBudget":
|
||||
return replace(self, attempts_left=self.attempts_left - 1)
|
||||
|
||||
def after_recovery(self, recovery: str) -> "_MigrateAttemptBudget":
|
||||
if recovery in self.recoveries:
|
||||
return self.spend()
|
||||
return replace(self, recoveries=self.recoveries | {recovery})
|
||||
|
||||
|
||||
_SPEND_LOGS_ALTER_RE = re.compile(r'^ALTER\s+TABLE\s+"LiteLLM_SpendLogs"\s', re.IGNORECASE)
|
||||
_SPEND_LOGS_ARTIFACT_DROP_RE = re.compile(
|
||||
r'^DROP\s+TABLE\s+"LiteLLM_SpendLogs_[^"]*"', re.IGNORECASE
|
||||
|
|
@ -198,7 +232,7 @@ class ProxyExtrasDBManager:
|
|||
# 1. Generate migration SQL file by comparing empty state to current db state
|
||||
logger.info("Generating baseline migration...")
|
||||
migration_file = init_dir / "migration.sql"
|
||||
subprocess.run(
|
||||
prisma_toolchain.run_prisma(
|
||||
[
|
||||
_get_prisma_command(),
|
||||
"migrate",
|
||||
|
|
@ -209,14 +243,13 @@ class ProxyExtrasDBManager:
|
|||
"--script",
|
||||
],
|
||||
stdout=open(migration_file, "w"),
|
||||
check=True,
|
||||
timeout=prisma_command_timeout(),
|
||||
env=prisma_env,
|
||||
)
|
||||
|
||||
# 3. Mark the migration as applied since it represents current state
|
||||
logger.info("Marking baseline migration as applied...")
|
||||
subprocess.run(
|
||||
prisma_toolchain.run_prisma(
|
||||
[
|
||||
_get_prisma_command(),
|
||||
"migrate",
|
||||
|
|
@ -224,7 +257,6 @@ class ProxyExtrasDBManager:
|
|||
"--applied",
|
||||
"0_init",
|
||||
],
|
||||
check=True,
|
||||
timeout=prisma_command_timeout(),
|
||||
env=prisma_env,
|
||||
)
|
||||
|
|
@ -253,7 +285,7 @@ class ProxyExtrasDBManager:
|
|||
"""Mark a specific migration as rolled back"""
|
||||
# Set up environment for offline mode if configured
|
||||
prisma_env = _get_prisma_env()
|
||||
subprocess.run(
|
||||
prisma_toolchain.run_prisma(
|
||||
[
|
||||
_get_prisma_command(),
|
||||
"migrate",
|
||||
|
|
@ -262,8 +294,6 @@ class ProxyExtrasDBManager:
|
|||
migration_name,
|
||||
],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
env=prisma_env,
|
||||
)
|
||||
|
||||
|
|
@ -315,11 +345,9 @@ class ProxyExtrasDBManager:
|
|||
def _resolve_specific_migration(migration_name: str):
|
||||
"""Mark a specific migration as applied"""
|
||||
prisma_env = _get_prisma_env()
|
||||
subprocess.run(
|
||||
prisma_toolchain.run_prisma(
|
||||
[_get_prisma_command(), "migrate", "resolve", "--applied", migration_name],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
env=prisma_env,
|
||||
)
|
||||
|
||||
|
|
@ -403,7 +431,7 @@ class ProxyExtrasDBManager:
|
|||
try:
|
||||
logger.info("Generating migration diff between DB and schema.prisma...")
|
||||
with open(diff_sql_path, "w") as f:
|
||||
subprocess.run(
|
||||
prisma_toolchain.run_prisma(
|
||||
[
|
||||
_get_prisma_command(),
|
||||
"migrate",
|
||||
|
|
@ -414,7 +442,6 @@ class ProxyExtrasDBManager:
|
|||
schema_path,
|
||||
"--script",
|
||||
],
|
||||
check=True,
|
||||
timeout=prisma_command_timeout(),
|
||||
stdout=f,
|
||||
env=_get_prisma_env(),
|
||||
|
|
@ -437,7 +464,7 @@ class ProxyExtrasDBManager:
|
|||
migration_files = sorted(Path(migrations_dir).glob("*/migration.sql"))
|
||||
for mig_file in migration_files:
|
||||
try:
|
||||
subprocess.run(
|
||||
prisma_toolchain.run_prisma(
|
||||
[
|
||||
_get_prisma_command(),
|
||||
"db",
|
||||
|
|
@ -448,9 +475,6 @@ class ProxyExtrasDBManager:
|
|||
schema_path,
|
||||
],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_get_prisma_env(),
|
||||
)
|
||||
logger.info(f"Applied migration: {mig_file.parent.name}")
|
||||
|
|
@ -483,7 +507,7 @@ class ProxyExtrasDBManager:
|
|||
applied_ok = False
|
||||
try:
|
||||
logger.info("Running prisma db execute to apply the migration diff...")
|
||||
result = subprocess.run(
|
||||
result = prisma_toolchain.run_prisma(
|
||||
[
|
||||
_get_prisma_command(),
|
||||
"db",
|
||||
|
|
@ -494,9 +518,6 @@ class ProxyExtrasDBManager:
|
|||
schema_path,
|
||||
],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_get_prisma_env(),
|
||||
)
|
||||
logger.info(f"prisma db execute stdout: {result.stdout}")
|
||||
|
|
@ -525,7 +546,7 @@ class ProxyExtrasDBManager:
|
|||
for migration_name in migration_names:
|
||||
try:
|
||||
logger.info(f"Resolving migration: {migration_name}")
|
||||
subprocess.run(
|
||||
prisma_toolchain.run_prisma(
|
||||
[
|
||||
_get_prisma_command(),
|
||||
"migrate",
|
||||
|
|
@ -534,9 +555,6 @@ class ProxyExtrasDBManager:
|
|||
migration_name,
|
||||
],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_get_prisma_env(),
|
||||
)
|
||||
logger.debug(f"Resolved migration: {migration_name}")
|
||||
|
|
@ -716,6 +734,9 @@ class ProxyExtrasDBManager:
|
|||
Ahead-of-HEAD state (DB has migrations newer than this build ships)
|
||||
is logged as a warning, not a fatal error — users whose DBs got into
|
||||
weird shapes from the old thrashing should still be able to start.
|
||||
|
||||
The retry budget only counts attempts that made no progress: see
|
||||
_MigrateAttemptBudget.
|
||||
"""
|
||||
schema_path = ProxyExtrasDBManager._get_prisma_dir() + "/schema.prisma"
|
||||
migrations_dir = ProxyExtrasDBManager._get_prisma_dir()
|
||||
|
|
@ -726,11 +747,12 @@ class ProxyExtrasDBManager:
|
|||
original_dir = os.getcwd()
|
||||
os.chdir(migrations_dir)
|
||||
try:
|
||||
subprocess.run(
|
||||
prisma_toolchain.run_prisma(
|
||||
[_get_prisma_command(), "db", "push", "--accept-data-loss"],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
env=_get_prisma_env(),
|
||||
stdout=None,
|
||||
stderr=None,
|
||||
)
|
||||
return True
|
||||
except (
|
||||
|
|
@ -749,15 +771,13 @@ class ProxyExtrasDBManager:
|
|||
original_dir = os.getcwd()
|
||||
os.chdir(migrations_dir)
|
||||
deploy_timeout = prisma_migrate_deploy_timeout()
|
||||
budget = _MigrateAttemptBudget(attempts_left=MAX_MIGRATE_DEPLOY_ATTEMPTS)
|
||||
try:
|
||||
for attempt in range(4):
|
||||
while not budget.exhausted:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
result = prisma_toolchain.run_prisma(
|
||||
[_get_prisma_command(), "migrate", "deploy"],
|
||||
timeout=deploy_timeout,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_get_prisma_env(),
|
||||
)
|
||||
logger.info(f"prisma migrate deploy stdout: {result.stdout}")
|
||||
|
|
@ -767,168 +787,155 @@ class ProxyExtrasDBManager:
|
|||
logger.warning(
|
||||
"prisma migrate deploy attempt %s timed out after %ss, retrying. "
|
||||
"Raise %s if this database needs longer to apply its pending migrations.",
|
||||
attempt + 1,
|
||||
budget.attempt_number,
|
||||
deploy_timeout,
|
||||
PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR,
|
||||
)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
continue
|
||||
next_budget = budget.spend()
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr = e.stderr or ""
|
||||
next_budget = ProxyExtrasDBManager._budget_after_deploy_failure(
|
||||
e, budget, schema_path
|
||||
)
|
||||
|
||||
if "P3005" in stderr and "database schema is not empty" in stderr:
|
||||
logger.info(
|
||||
"Schema exists but no migrations ledger — creating baseline"
|
||||
)
|
||||
ProxyExtrasDBManager._create_baseline_migration(schema_path)
|
||||
continue
|
||||
|
||||
if "P3009" in stderr:
|
||||
migration_match = re.search(r"`(\d+_\S+?)`", stderr)
|
||||
if (
|
||||
migration_match
|
||||
and ProxyExtrasDBManager._is_idempotent_error(stderr)
|
||||
):
|
||||
name = migration_match.group(1)
|
||||
logger.info(
|
||||
f"Migration {name} failed idempotently — marking applied and retrying"
|
||||
)
|
||||
try:
|
||||
ProxyExtrasDBManager._roll_back_migration(name)
|
||||
except (
|
||||
subprocess.CalledProcessError,
|
||||
subprocess.TimeoutExpired,
|
||||
):
|
||||
pass # may already be rolled-back
|
||||
try:
|
||||
ProxyExtrasDBManager._resolve_specific_migration(name)
|
||||
except (
|
||||
subprocess.CalledProcessError,
|
||||
subprocess.TimeoutExpired,
|
||||
) as resolve_err:
|
||||
# We're already inside the outer
|
||||
# `except CalledProcessError` handler —
|
||||
# re-raising CalledProcessError from here
|
||||
# would escape as itself, bypassing
|
||||
# proxy_cli.py's `except RuntimeError`.
|
||||
raise RuntimeError(
|
||||
f"Failed to mark migration {name} as applied "
|
||||
f"after idempotent recovery. Manual "
|
||||
f"intervention may be required.\n\n"
|
||||
f"Detail: {resolve_err}"
|
||||
) from resolve_err
|
||||
continue
|
||||
if migration_match:
|
||||
migration_name = migration_match.group(1)
|
||||
ledger_logs = ProxyExtrasDBManager._failed_migration_logs(migration_name)
|
||||
if ledger_logs is not None and (
|
||||
ledger_logs == "" or _MIGRATION_DEADLOCK_MARKER in ledger_logs
|
||||
):
|
||||
logger.info(
|
||||
"Migration %s failed in a concurrent migrate deploy "
|
||||
"deadlock race, rolling its ledger row back and retrying",
|
||||
migration_name,
|
||||
)
|
||||
ProxyExtrasDBManager._roll_back_migration_best_effort(migration_name)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
) from e
|
||||
|
||||
if "P3018" in stderr:
|
||||
if ProxyExtrasDBManager._is_permission_error(stderr):
|
||||
raise RuntimeError(
|
||||
"Database migration failed due to insufficient "
|
||||
"permissions. Please grant the required privileges "
|
||||
f"and retry.\n\nPrisma error:\n{stderr}"
|
||||
) from e
|
||||
|
||||
migration_match = re.search(
|
||||
r"Migration name: (\d+_\S+)", stderr
|
||||
)
|
||||
if (
|
||||
migration_match
|
||||
and ProxyExtrasDBManager._is_idempotent_error(stderr)
|
||||
):
|
||||
name = migration_match.group(1)
|
||||
logger.info(
|
||||
f"Migration {name} SQL hit idempotent error — marking applied and retrying"
|
||||
)
|
||||
try:
|
||||
ProxyExtrasDBManager._roll_back_migration(name)
|
||||
except (
|
||||
subprocess.CalledProcessError,
|
||||
subprocess.TimeoutExpired,
|
||||
):
|
||||
pass # may already be rolled-back
|
||||
try:
|
||||
ProxyExtrasDBManager._resolve_specific_migration(name)
|
||||
except (
|
||||
subprocess.CalledProcessError,
|
||||
subprocess.TimeoutExpired,
|
||||
) as resolve_err:
|
||||
raise RuntimeError(
|
||||
f"Failed to mark migration {name} as applied "
|
||||
f"after idempotent recovery. Manual "
|
||||
f"intervention may be required.\n\n"
|
||||
f"Detail: {resolve_err}"
|
||||
) from resolve_err
|
||||
continue
|
||||
|
||||
if migration_match and _MIGRATION_DEADLOCK_MARKER in stderr:
|
||||
logger.info(
|
||||
"Migration %s deadlocked against a concurrent "
|
||||
"migrate deploy, rolling its ledger row back "
|
||||
"and retrying",
|
||||
migration_match.group(1),
|
||||
)
|
||||
ProxyExtrasDBManager._roll_back_migration_best_effort(
|
||||
migration_match.group(1)
|
||||
)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
) from e
|
||||
|
||||
if _MIGRATION_DEADLOCK_MARKER in stderr:
|
||||
logger.info(
|
||||
"prisma migrate deploy attempt %s deadlocked against "
|
||||
"a concurrent migrate deploy, retrying",
|
||||
attempt + 1,
|
||||
)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
continue
|
||||
|
||||
if "P1002" in stderr and "advisory lock" in stderr:
|
||||
logger.info(
|
||||
"prisma migrate deploy attempt %s timed out waiting for "
|
||||
"the advisory lock a concurrent migrate deploy holds, retrying",
|
||||
attempt + 1,
|
||||
)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
) from e
|
||||
if next_budget.attempts_left < budget.attempts_left:
|
||||
time.sleep(random.randrange(5, 15))
|
||||
budget = next_budget # rebind-ok: the loop carries the budget from one migrate deploy pass to the next
|
||||
|
||||
raise RuntimeError(
|
||||
"Database migration failed after 4 attempts (retry loop "
|
||||
"exhausted by timeouts, deadlock retries, or repeated "
|
||||
"idempotent-recovery continues). Check database connectivity, "
|
||||
f"Database migration failed after {MAX_MIGRATE_DEPLOY_ATTEMPTS} "
|
||||
"attempts that made no progress (timeouts, deadlock retries, or a "
|
||||
"recovery that had already run once). Check database connectivity, "
|
||||
"load, and _prisma_migrations ledger state, and raise "
|
||||
f"{PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR} if the attempts timed out."
|
||||
)
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
|
||||
@staticmethod
|
||||
def _budget_after_deploy_failure(
|
||||
error: subprocess.CalledProcessError,
|
||||
budget: "_MigrateAttemptBudget",
|
||||
schema_path: str,
|
||||
) -> "_MigrateAttemptBudget":
|
||||
"""Recover from one failed `prisma migrate deploy`, and price the pass.
|
||||
|
||||
Returns the budget the next pass runs under, or raises when the failure
|
||||
is not one this resolver knows how to recover from.
|
||||
"""
|
||||
stderr = error.stderr or ""
|
||||
|
||||
if "P3005" in stderr and "database schema is not empty" in stderr:
|
||||
logger.info("Schema exists but no migrations ledger — creating baseline")
|
||||
if ProxyExtrasDBManager._create_baseline_migration(schema_path):
|
||||
return budget.after_recovery("baseline")
|
||||
return budget.spend()
|
||||
|
||||
if "P3009" in stderr:
|
||||
migration_match = re.search(r"`(\d+_\S+?)`", stderr)
|
||||
if migration_match and ProxyExtrasDBManager._is_idempotent_error(stderr):
|
||||
name = migration_match.group(1)
|
||||
logger.info(
|
||||
f"Migration {name} failed idempotently — marking applied and retrying"
|
||||
)
|
||||
ProxyExtrasDBManager._mark_migration_applied(name)
|
||||
return budget.after_recovery(f"resolved:{name}")
|
||||
if migration_match:
|
||||
migration_name = migration_match.group(1)
|
||||
ledger_logs = ProxyExtrasDBManager._failed_migration_logs(migration_name)
|
||||
if ledger_logs is not None and (
|
||||
ledger_logs == "" or _MIGRATION_DEADLOCK_MARKER in ledger_logs
|
||||
):
|
||||
logger.info(
|
||||
"Migration %s failed in a concurrent migrate deploy "
|
||||
"deadlock race, rolling its ledger row back and retrying",
|
||||
migration_name,
|
||||
)
|
||||
ProxyExtrasDBManager._roll_back_migration_best_effort(migration_name)
|
||||
return budget.spend()
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
) from error
|
||||
|
||||
if "P3018" in stderr:
|
||||
if ProxyExtrasDBManager._is_permission_error(stderr):
|
||||
raise RuntimeError(
|
||||
"Database migration failed due to insufficient "
|
||||
"permissions. Please grant the required privileges "
|
||||
f"and retry.\n\nPrisma error:\n{stderr}"
|
||||
) from error
|
||||
|
||||
migration_match = re.search(r"Migration name: (\d+_\S+)", stderr)
|
||||
if migration_match and ProxyExtrasDBManager._is_idempotent_error(stderr):
|
||||
name = migration_match.group(1)
|
||||
logger.info(
|
||||
f"Migration {name} SQL hit idempotent error — marking applied and retrying"
|
||||
)
|
||||
ProxyExtrasDBManager._mark_migration_applied(name)
|
||||
return budget.after_recovery(f"resolved:{name}")
|
||||
|
||||
if migration_match and _MIGRATION_DEADLOCK_MARKER in stderr:
|
||||
logger.info(
|
||||
"Migration %s deadlocked against a concurrent "
|
||||
"migrate deploy, rolling its ledger row back "
|
||||
"and retrying",
|
||||
migration_match.group(1),
|
||||
)
|
||||
ProxyExtrasDBManager._roll_back_migration_best_effort(
|
||||
migration_match.group(1)
|
||||
)
|
||||
return budget.spend()
|
||||
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
) from error
|
||||
|
||||
if _MIGRATION_DEADLOCK_MARKER in stderr:
|
||||
logger.info(
|
||||
"prisma migrate deploy attempt %s deadlocked against "
|
||||
"a concurrent migrate deploy, retrying",
|
||||
budget.attempt_number,
|
||||
)
|
||||
return budget.spend()
|
||||
|
||||
if "P1002" in stderr and "advisory lock" in stderr:
|
||||
logger.info(
|
||||
"prisma migrate deploy attempt %s timed out waiting for "
|
||||
"the advisory lock a concurrent migrate deploy holds, retrying",
|
||||
budget.attempt_number,
|
||||
)
|
||||
return budget.spend()
|
||||
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
) from error
|
||||
|
||||
@staticmethod
|
||||
def _mark_migration_applied(name: str) -> None:
|
||||
"""Roll a failed ledger row back if it is still there, then mark it applied."""
|
||||
try:
|
||||
ProxyExtrasDBManager._roll_back_migration(name)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
pass # may already be rolled-back
|
||||
try:
|
||||
ProxyExtrasDBManager._resolve_specific_migration(name)
|
||||
except (
|
||||
subprocess.CalledProcessError,
|
||||
subprocess.TimeoutExpired,
|
||||
) as resolve_err:
|
||||
# We're called from inside an `except CalledProcessError` handler —
|
||||
# re-raising CalledProcessError from here would escape as itself,
|
||||
# bypassing proxy_cli.py's `except RuntimeError`.
|
||||
raise RuntimeError(
|
||||
f"Failed to mark migration {name} as applied "
|
||||
f"after idempotent recovery. Manual "
|
||||
f"intervention may be required.\n\n"
|
||||
f"Detail: {resolve_err}"
|
||||
) from resolve_err
|
||||
|
||||
@staticmethod
|
||||
def apply_replica_identity_full_if_requested() -> bool:
|
||||
"""
|
||||
|
|
@ -1007,12 +1014,9 @@ class ProxyExtrasDBManager:
|
|||
logger.info("Running prisma migrate deploy")
|
||||
try:
|
||||
# Set migrations directory for Prisma
|
||||
result = subprocess.run(
|
||||
result = prisma_toolchain.run_prisma(
|
||||
[_get_prisma_command(), "migrate", "deploy"],
|
||||
timeout=prisma_migrate_deploy_timeout(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_get_prisma_env(),
|
||||
)
|
||||
logger.info(f"prisma migrate deploy stdout: {result.stdout}")
|
||||
|
|
@ -1084,7 +1088,7 @@ class ProxyExtrasDBManager:
|
|||
f"Found failed migration: {failed_migration}, marking as rolled back"
|
||||
)
|
||||
# Mark the failed migration as rolled back
|
||||
subprocess.run(
|
||||
prisma_toolchain.run_prisma(
|
||||
[
|
||||
_get_prisma_command(),
|
||||
"migrate",
|
||||
|
|
@ -1093,9 +1097,6 @@ class ProxyExtrasDBManager:
|
|||
failed_migration,
|
||||
],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_get_prisma_env(),
|
||||
)
|
||||
logger.info(
|
||||
|
|
@ -1220,10 +1221,12 @@ class ProxyExtrasDBManager:
|
|||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
|
||||
# Use prisma db push with increased timeout
|
||||
subprocess.run(
|
||||
prisma_toolchain.run_prisma(
|
||||
[_get_prisma_command(), "db", "push", "--accept-data-loss"],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
stdout=None,
|
||||
stderr=None,
|
||||
env=_get_prisma_env(),
|
||||
)
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.92"
|
||||
version = "0.4.94"
|
||||
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.92"
|
||||
version = "0.4.94"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ def test_v2_p3018_permission_error_raises_runtime_error(monkeypatch, tmp_path):
|
|||
"Error: P3018\nMigration name: 20250326162113_baseline\n"
|
||||
"Database error code: 42501\npermission denied for schema public"
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="permission"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ def test_v2_non_idempotent_p3009_raises_runtime_error(monkeypatch, tmp_path):
|
|||
"Error: P3009\nMigration `20260101000000_genuinely_broken` failed\n"
|
||||
'Reason: syntax error at or near "BRKN" LINE 42'
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ def test_v1_default_still_calls_resolve_all_migrations(monkeypatch, tmp_path):
|
|||
def fake_resolve(*args, **kwargs):
|
||||
resolve_called["n"] += 1
|
||||
|
||||
monkeypatch.setattr("subprocess.run", fake_run)
|
||||
monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", fake_run)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_resolve_all_migrations", fake_resolve)
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True) # v2 flag NOT set
|
||||
|
|
@ -139,7 +139,7 @@ def test_v2_db_push_wraps_subprocess_error_as_runtime_error(monkeypatch, tmp_pat
|
|||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
stderr = "db push error"
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="prisma db push failed"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True)
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ def test_v2_resolve_specific_migration_failure_raises_runtime_error(
|
|||
"Error: P3009\nMigration `20260101000000_some_migration` failed\n"
|
||||
"relation already exists"
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(
|
||||
RuntimeError, match="Failed to mark migration .* as applied"
|
||||
):
|
||||
|
|
@ -228,7 +228,7 @@ def test_v2_does_not_call_resolve_all_migrations(monkeypatch, tmp_path):
|
|||
stdout = "Applied migration.\n"
|
||||
stderr = ""
|
||||
|
||||
monkeypatch.setattr("subprocess.run", lambda *a, **kw: FakeResult())
|
||||
monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", lambda *a, **kw: FakeResult())
|
||||
|
||||
resolve_called = {"n": 0}
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -296,7 +296,7 @@ def test_v2_p3018_deadlock_rolls_back_and_retries(monkeypatch, tmp_path):
|
|||
"_resolve_specific_migration",
|
||||
lambda name: pytest.fail("a deadlocked migration must never be marked applied"),
|
||||
)
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(1, _DEADLOCK_P3018_STDERR))
|
||||
monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, _DEADLOCK_P3018_STDERR))
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
|
|
@ -309,7 +309,7 @@ def test_v2_p3018_persistent_deadlock_exhausts_attempts(monkeypatch, tmp_path):
|
|||
monkeypatch.setattr(ProxyExtrasDBManager, "_roll_back_migration", lambda name: None)
|
||||
|
||||
with patch(
|
||||
"subprocess.run",
|
||||
"litellm_proxy_extras.prisma_toolchain.run_prisma",
|
||||
side_effect=_fake_migrate_deploy_failure(1, _DEADLOCK_P3018_STDERR),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="after 4 attempts"):
|
||||
|
|
@ -343,7 +343,7 @@ def test_v2_p3009_deadlocked_ledger_row_rolls_back_and_retries(monkeypatch, tmp_
|
|||
"_resolve_specific_migration",
|
||||
lambda name: pytest.fail("a deadlocked migration must never be marked applied"),
|
||||
)
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr))
|
||||
monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, stderr))
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
|
|
@ -372,7 +372,7 @@ def test_v2_p3009_empty_ledger_logs_rolls_back_and_retries(monkeypatch, tmp_path
|
|||
"_resolve_specific_migration",
|
||||
lambda name: pytest.fail("a deadlocked migration must never be marked applied"),
|
||||
)
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr))
|
||||
monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, stderr))
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
|
|
@ -395,7 +395,7 @@ def test_v2_p3009_unreadable_ledger_still_raises(monkeypatch, tmp_path):
|
|||
"_roll_back_migration",
|
||||
lambda name: pytest.fail("an unreadable ledger must not trigger a retry"),
|
||||
)
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr))
|
||||
monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, stderr))
|
||||
|
||||
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
|
@ -417,7 +417,7 @@ def test_v2_p3009_non_deadlock_ledger_row_still_raises(monkeypatch, tmp_path):
|
|||
lambda name: 'ERROR: syntax error at or near "BRKN"',
|
||||
)
|
||||
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
|
@ -427,7 +427,7 @@ def test_v2_bare_deadlock_stderr_retries(monkeypatch, tmp_path):
|
|||
waiter as victim) is retried, not fatal."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"subprocess.run", _succeed_after(1, "Database error: deadlock detected")
|
||||
"litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, "Database error: deadlock detected")
|
||||
)
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
|
@ -446,7 +446,10 @@ def test_v2_advisory_lock_timeout_retries(monkeypatch, tmp_path):
|
|||
"""v2: the advisory-lock waiter that times out while a peer's retry holds
|
||||
the lock retries instead of dying."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(2, _P1002_ADVISORY_LOCK_STDERR))
|
||||
monkeypatch.setattr(
|
||||
"litellm_proxy_extras.prisma_toolchain.run_prisma",
|
||||
_succeed_after(2, _P1002_ADVISORY_LOCK_STDERR),
|
||||
)
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
|
|
@ -456,7 +459,7 @@ def test_v2_p1002_without_advisory_lock_context_still_raises(monkeypatch, tmp_pa
|
|||
"""v2: a plain P1002 (database unreachable) stays fatal."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
stderr = "Error: P1002\n\nThe database server at `db`:`5432` was reached but timed out."
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr))
|
||||
monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, stderr))
|
||||
|
||||
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
# AGENTS.md
|
||||
|
||||
litellm-rust has four crates. A crate is a layer or shared foundation, not a route. Routes (ocr, realtime, chat) and providers (mistral, openai) are modules inside the layers.
|
||||
litellm-rust has five crates. A crate is a layer or shared foundation, not a route. Routes (ocr, realtime, chat) and providers (mistral, openai) are modules inside the layers.
|
||||
|
||||
## Crates
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| litellm-core | The LiteLLM SDK in Rust. One public entrypoint per top-level call (`messages::messages()`), owning types, transforms, provider resolution, auth, and the provider HTTP call. Call it, get a typed response. |
|
||||
| litellm-config | Config-loading boundary. Returns resolved core deployment data and optionally delegates loading to Python. |
|
||||
| litellm-ai-gateway | The axum server (behind the `server` feature) plus the WebSocket hosts. Translates HTTP/WS to core entrypoints; owns no provider logic and no handlers. |
|
||||
| litellm-python-interop | Domain-neutral PyO3 foundation for GIL handling and typed Python/Serde conversion. |
|
||||
| litellm-python-bridge | PyO3 cdylib exposing LiteLLM Rust APIs to the Python SDK. Owns API registration, domain wiring, and Python exception mapping. |
|
||||
|
||||
Dependency direction is acyclic: `litellm-python-bridge` depends on the domain layers and `litellm-python-interop`; the interop foundation depends on no LiteLLM domain crate.
|
||||
Dependency direction is acyclic: `litellm-config` depends on `litellm-core`, the gateway depends on both, and `litellm-python-bridge` depends on the domain layers and `litellm-python-interop`. The interop foundation depends on no LiteLLM domain crate.
|
||||
|
||||
## Where a route lives
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ the base when behavior is genuinely different, and say so explicitly in the PR.
|
|||
## Crates (see AGENTS.md)
|
||||
|
||||
`litellm-core` **is** the LiteLLM SDK in Rust: it makes the LLM call.
|
||||
`litellm-config` is the config-loading boundary and returns resolved core types.
|
||||
`litellm-ai-gateway` is an HTTP/WebSocket server in front of it, and
|
||||
`litellm-python-bridge` exposes it to the Python SDK. `litellm-python-interop`
|
||||
holds domain-neutral PyO3 primitives shared by Python-facing Rust code. A crate
|
||||
|
|
|
|||
33
litellm-rust/Cargo.lock
generated
33
litellm-rust/Cargo.lock
generated
|
|
@ -1412,8 +1412,8 @@ dependencies = [
|
|||
"base64",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"litellm-config",
|
||||
"litellm-core",
|
||||
"pyo3",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1425,6 +1425,16 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"litellm-core",
|
||||
"pyo3",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-core"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1435,14 +1445,17 @@ dependencies = [
|
|||
"aws-sigv4",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-types",
|
||||
"base64",
|
||||
"rand 0.8.7",
|
||||
"reqwest",
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1461,7 +1474,6 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1511,6 +1523,16 @@ version = "0.3.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
|
|
@ -1969,6 +1991,7 @@ dependencies = [
|
|||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
|
|
@ -2736,6 +2759,12 @@ version = "1.20.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"crates/core",
|
||||
"crates/config",
|
||||
"crates/ai-gateway",
|
||||
"crates/python-interop",
|
||||
"crates/python-bridge",
|
||||
|
|
@ -17,6 +18,7 @@ repository = "https://github.com/BerriAI/litellm"
|
|||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] }
|
||||
litellm-core = { path = "crates/core" }
|
||||
litellm-config = { path = "crates/config" }
|
||||
litellm-ai-gateway = { path = "crates/ai-gateway", default-features = false }
|
||||
litellm-python-interop = { path = "crates/python-interop" }
|
||||
axum = "0.7"
|
||||
|
|
@ -24,10 +26,10 @@ pyo3 = "0.29.2"
|
|||
pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] }
|
||||
pythonize = "0.29.0"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "http2", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "http2", "stream"] }
|
||||
rstest = "0.26.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_json = { version = "1.0", features = ["float_roundtrip"] }
|
||||
sha2 = "0.10"
|
||||
subtle = "2"
|
||||
thiserror = "2.0"
|
||||
|
|
|
|||
|
|
@ -25,11 +25,12 @@ coverage and production evidence.
|
|||
| Crate | Role |
|
||||
|-------|------|
|
||||
| litellm-core | The SDK. Per-route entrypoints (`messages::messages()`), types, provider transforms (modules under `providers/`), provider resolution, auth, the provider HTTP call, and the router. |
|
||||
| litellm-config | Config-loading boundary. Returns resolved deployments and optionally delegates loading to Python. |
|
||||
| litellm-ai-gateway | The axum server (behind the `server` feature) and WebSocket hosts. Translates HTTP/WS to core entrypoints; no provider handlers. |
|
||||
| litellm-python-interop | Domain-neutral PyO3 foundation for GIL handling and typed Python/Serde conversion. |
|
||||
| litellm-python-bridge | PyO3 cdylib exposing LiteLLM Rust APIs to the Python SDK. Owns API registration, domain wiring, and Python exception mapping. |
|
||||
|
||||
Dependency direction is acyclic: `litellm-python-bridge` depends on the domain layers and `litellm-python-interop`; the interop foundation depends on no LiteLLM domain crate.
|
||||
Dependency direction is acyclic: config depends on core, the gateway depends on config and core, and the Python bridge depends on the domain layers and Python interop.
|
||||
|
||||
## Layout
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ crates/
|
|||
core/ The SDK: route modules + provider transforms.
|
||||
src/messages/ mod.rs (entrypoint), types, transformation, prepare, handler, client
|
||||
src/providers/anthropic/messages/transformation.rs
|
||||
config/ Config loading and resolved deployments.
|
||||
ai-gateway/ Axum server + WebSocket hosts; calls core entrypoints.
|
||||
python-interop/ Domain-neutral PyO3 conversion and GIL primitives.
|
||||
python-bridge/ PyO3 API adapter for Python LiteLLM.
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ Rules for adding or changing an LLM provider/route in `litellm-rust`. `messages`
|
|||
|
||||
22. A Python -> Rust bridge keeps the Python side minimal: the Python interface only marshals inputs and calls the Rust interface, with no transform, handler, or business logic. Aim for well under 100 lines of interface code per route; if the Python grows past that, the logic belongs in Rust.
|
||||
23. Do not bloat `litellm/main.py`. A route's provider dispatch lives in a thin dispatch class under `litellm/llms/<provider>/<route>/` that calls the Rust bridge; `main.py` only instantiates it and calls its sync/async method.
|
||||
24. Do not add new feature flags unless explicitly requested. Reuse the existing litellm rust rollout mechanism (`use_litellm_rust`); never introduce a per-route env flag such as `LITELLM_USE_RUST_<ROUTE>`.
|
||||
24. Do not add new feature flags unless explicitly requested. Reuse the existing LiteLLM Rust rollout mechanism (`litellm.rust`); never introduce a per-route env flag such as `LITELLM_USE_RUST_<ROUTE>`.
|
||||
|
||||
## Checks before push
|
||||
|
||||
|
|
|
|||
|
|
@ -9,19 +9,15 @@ such as `litellm_core::messages::messages`. No provider handler lives here.
|
|||
src/
|
||||
main.rs # entrypoint: build AppState (router + master key), bind, serve
|
||||
state.rs # AppState — shared Arc<Router> + master_key
|
||||
gil.rs # GIL-activity tracker (records Python acquisitions)
|
||||
auth/ # authentication as an axum extractor — added to handler args
|
||||
mod.rs # RequireMasterKey: FromRequestParts, single master key (LITELLM_MASTER_KEY)
|
||||
routes/ # one module per route, all matching the same template
|
||||
AGENTS.md # ← the route template (read this before adding a route)
|
||||
mod.rs # app(): merges every module's router()
|
||||
health.rs # simple route (one file): router() + liveness/readiness
|
||||
gil.rs # simple route (one file): router() + GET /health/gil
|
||||
realtime/ # route with logic → axum surface + a no-axum service:
|
||||
mod.rs # router() + handler + WS<->events adapter (the axum surface)
|
||||
service.rs # business logic (select deployment, call provider) — no axum, testable
|
||||
python/ # Python interop (feature: python-config) — load-time only
|
||||
mod.rs, config.rs, AGENTS.md
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
|
@ -53,5 +49,6 @@ proxy in a later phase. Health routes don't add the extractor (unauthenticated).
|
|||
|
||||
## Python interop
|
||||
|
||||
Anything that calls into Python lives in `python/` and is **load-time only** — see
|
||||
`python/AGENTS.md`. The realtime data path never takes the GIL.
|
||||
Python-backed loading lives in `litellm-config` and is **load-time only**. The
|
||||
gateway's `python-config` feature forwards to that crate. The realtime data path
|
||||
never takes the GIL.
|
||||
|
|
|
|||
|
|
@ -9,4 +9,6 @@ flowchart LR
|
|||
C[client] <--> G[Rust ai-gateway<br/>LLM inference]
|
||||
G <--> O[OpenAI realtime]
|
||||
G -. spend tracking callback .-> P[litellm proxy]
|
||||
F[litellm-config<br/>load-time only] --> G
|
||||
F -. Python backend .-> P
|
||||
```
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ required-features = ["server"]
|
|||
[dependencies]
|
||||
tracing.workspace = true
|
||||
litellm-core = { workspace = true, features = ["bedrock-auth"] }
|
||||
litellm-config.workspace = true
|
||||
# reqwest (rustls + json) is used by io/ocr and ships realtime logs to the
|
||||
# Python proxy callbacks API.
|
||||
reqwest.workspace = true
|
||||
|
|
@ -31,14 +32,15 @@ subtle = { workspace = true, optional = true }
|
|||
# sha2 hashes the master key into user_api_key_hash (matches the proxy's
|
||||
# SHA-256 hash_token) so the plaintext credential never enters a log payload.
|
||||
sha2 = { workspace = true, optional = true }
|
||||
pyo3 = { workspace = true, features = ["auto-initialize"], optional = true }
|
||||
tower = { version = "0.5.3", features = ["util"], optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
server = ["dep:axum", "dep:subtle", "dep:sha2"]
|
||||
# Build the gateway's config from the proxy YAML via an embedded Python
|
||||
# interpreter (links libpython; requires `litellm` importable at runtime).
|
||||
python-config = ["dep:pyo3"]
|
||||
python-config = ["litellm-config/python"]
|
||||
trace-parity = ["server", "dep:tower", "litellm-core/observability"]
|
||||
|
||||
[dev-dependencies]
|
||||
futures-channel = "0.3"
|
||||
|
|
|
|||
|
|
@ -6,25 +6,30 @@ dials OpenAI upstream, and splices the two sockets frame-by-frame.
|
|||
|
||||
## Crates
|
||||
|
||||
`litellm-rust` has four crates. A crate is a layer or shared foundation, not a route:
|
||||
`litellm-rust` has five crates. A crate is a layer or shared foundation, not a route:
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| litellm-core | The LiteLLM SDK in Rust — per-route entrypoints (`messages::messages()`) that resolve the provider, transform, and make the call; plus types, provider transforms, and the router. |
|
||||
| litellm-config | Config-loading boundary. Returns resolved deployments and optionally delegates loading to Python. |
|
||||
| litellm-ai-gateway | The Axum server (behind the `server` feature) and WebSocket hosts. Translates HTTP/WS to core entrypoints; no provider handlers. |
|
||||
| litellm-python-interop | Domain-neutral PyO3 foundation for GIL handling and typed Python/Serde conversion. |
|
||||
| litellm-python-bridge | PyO3 cdylib exposing LiteLLM Rust APIs to the Python SDK. |
|
||||
|
||||
Dependency direction is acyclic: `litellm-python-bridge` depends on the domain layers and `litellm-python-interop`; the interop foundation depends on no LiteLLM domain crate.
|
||||
Dependency direction is acyclic: config depends on core, the gateway depends on config and core, and the Python bridge depends on the domain layers and Python interop.
|
||||
|
||||
- **Client endpoint:** `wss://<host>/v1/realtime?model=<model>` (WebSocket)
|
||||
- **Auth:** `Authorization: Bearer $LITELLM_MASTER_KEY` (fails closed if unset)
|
||||
- **Health:** `GET /health/readiness`, `GET /health/liveness`, `GET /health/gil`
|
||||
- **Health:** `GET /health/readiness`, `GET /health/liveness`
|
||||
- **Request logs:** POSTed to a LiteLLM proxy at `/v1/rust_control_plane/logs` (see [Request logging](#request-logging))
|
||||
|
||||
> **Realtime serving is pure Rust.** Python is used at **load time only** — to
|
||||
> read the config once at boot. The realtime hot path never touches Python.
|
||||
|
||||
The former `/health/gil` route and its acquisition counter were removed. They
|
||||
only observed the single startup config load and did not prove that every GIL
|
||||
acquisition was instrumented
|
||||
|
||||
## Configuration (config.yaml)
|
||||
|
||||
The gateway loads its `model_list` from a **config.yaml**, the same as the
|
||||
|
|
@ -43,9 +48,10 @@ model_list:
|
|||
LITELLM_CONFIG_PATH=./config.yaml ./litellm-ai-gateway
|
||||
```
|
||||
|
||||
At boot the gateway calls into `litellm.proxy.read_model_list`, which reuses the
|
||||
**real proxy config reader** (`ProxyConfig.get_config`). That means everything
|
||||
the proxy supports in config.yaml works here too:
|
||||
At boot `litellm-config` calls into `litellm.proxy.read_model_list` and returns
|
||||
resolved deployments to the gateway, which constructs the router. The Python
|
||||
backend still reuses the **real proxy config reader** (`ProxyConfig.get_config`),
|
||||
so everything the proxy supports in config.yaml works here too:
|
||||
|
||||
- `include:` to merge in other config files,
|
||||
- `os.environ/VAR` secret references (resolved via the secret manager, never
|
||||
|
|
@ -82,8 +88,8 @@ stand-in built from the environment:
|
|||
|---|---|---|
|
||||
| `OPENAI_REALTIME_MODEL` | `gpt-realtime` | The single deployment's model name (also the `?model=` clients pass). |
|
||||
|
||||
This mode links no libpython and needs no config file, but it only supports one
|
||||
hard-coded OpenAI deployment. **config.yaml is the recommended path** — use the
|
||||
The default workspace build links no libpython and needs no config file. This
|
||||
fallback mode only supports one hard-coded OpenAI deployment. **config.yaml is the recommended path** — use the
|
||||
stand-in only for the leanest possible build.
|
||||
|
||||
## Request logging
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Sample realtime config for the LiteLLM Rust AI Gateway.
|
||||
#
|
||||
# The gateway loads this model_list at boot via the embedded python config
|
||||
# reader (litellm.proxy.read_model_list), which reuses the proxy's own reader —
|
||||
# so include:, os.environ/ secrets, and DB-stored models all work here too.
|
||||
# litellm-config resolves this model_list at boot through the Python config
|
||||
# reader (litellm.proxy.read_model_list), then the gateway builds its router.
|
||||
# Includes, environment secrets, and database-stored models still work.
|
||||
#
|
||||
# Secrets are referenced (never inlined) via os.environ/. A real deploy can
|
||||
# override this file (e.g. mount a Render secret file at LITELLM_CONFIG_PATH).
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
//! GIL-activity tracking.
|
||||
//!
|
||||
//! Every acquisition of the Python GIL is recorded here so the `/health/gil`
|
||||
//! endpoint can report whether Python was touched recently. The design goal is
|
||||
//! that the GIL is acquired **only at load time** (config read) and never on the
|
||||
//! realtime hot path — polling this endpoint during traffic should show the
|
||||
//! count holding steady and `acquired_last_30s` falling to `false`.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Window (seconds) for the "recently acquired" signal.
|
||||
pub const RECENT_WINDOW_SECS: u64 = 30;
|
||||
|
||||
static GIL_ACQUISITIONS: AtomicU64 = AtomicU64::new(0);
|
||||
/// Unix seconds of the last acquisition; `0` means "never".
|
||||
static LAST_GIL_UNIX_SECS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn now_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Record that the GIL was just acquired. Call immediately before taking the GIL.
|
||||
///
|
||||
/// Only invoked under the `python-config` feature; without it the gateway never
|
||||
/// touches Python, so the recorder is unused (and the endpoint reports zero).
|
||||
#[cfg_attr(not(feature = "python-config"), allow(dead_code))]
|
||||
pub fn record_acquisition() {
|
||||
GIL_ACQUISITIONS.fetch_add(1, Ordering::Relaxed);
|
||||
LAST_GIL_UNIX_SECS.store(now_unix_secs(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Point-in-time view of GIL activity.
|
||||
pub struct GilSnapshot {
|
||||
pub total_acquisitions: u64,
|
||||
pub seconds_since_last: Option<u64>,
|
||||
pub acquired_last_30s: bool,
|
||||
}
|
||||
|
||||
/// Read the current GIL-activity snapshot.
|
||||
pub fn snapshot() -> GilSnapshot {
|
||||
let total = GIL_ACQUISITIONS.load(Ordering::Relaxed);
|
||||
let last = LAST_GIL_UNIX_SECS.load(Ordering::Relaxed);
|
||||
let seconds_since_last = if last == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(now_unix_secs().saturating_sub(last))
|
||||
};
|
||||
let acquired_last_30s = seconds_since_last.is_some_and(|secs| secs <= RECENT_WINDOW_SECS);
|
||||
GilSnapshot {
|
||||
total_acquisitions: total,
|
||||
seconds_since_last,
|
||||
acquired_last_30s,
|
||||
}
|
||||
}
|
||||
|
|
@ -10,29 +10,23 @@
|
|||
//! - [`io`]: compatibility exports and realtime WebSocket splice helpers.
|
||||
//! - The server modules ([`auth`], [`routes`], [`state`]) and anything pulling
|
||||
//! `axum` are gated behind the `server` feature, which the `litellm-ai-gateway`
|
||||
//! binary turns on. The `python-config` feature additionally pulls in [`python`]
|
||||
//! for the load-time config reader.
|
||||
//! binary turns on.
|
||||
|
||||
pub mod audio_transcription;
|
||||
mod client;
|
||||
pub mod io;
|
||||
pub mod ocr;
|
||||
|
||||
/// GIL-activity tracking. Pure (atomics only); shared by the `server` routes and
|
||||
/// the `python-config` reader, so it is available without either feature.
|
||||
pub mod gil;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub mod auth;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod routes;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod state;
|
||||
#[cfg(feature = "trace-parity")]
|
||||
pub mod trace_parity;
|
||||
|
||||
mod constants;
|
||||
pub mod integrations;
|
||||
#[cfg(feature = "server")]
|
||||
mod realtime;
|
||||
|
||||
#[cfg(feature = "python-config")]
|
||||
pub mod python;
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ use std::sync::Arc;
|
|||
use litellm_ai_gateway::io::realtime_pool::{PoolConfig, RealtimePool, upstream_key};
|
||||
use litellm_ai_gateway::routes;
|
||||
use litellm_ai_gateway::state::AppState;
|
||||
#[cfg(feature = "python-config")]
|
||||
use litellm_config::load_model_list;
|
||||
use litellm_core::router::{Deployment, LiteLLMParams, Router};
|
||||
|
||||
use litellm_ai_gateway::integrations::custom_logger::CustomLogger;
|
||||
use litellm_ai_gateway::integrations::litellm_python_proxy_api::LiteLLMPythonProxyAPILogger;
|
||||
#[cfg(feature = "python-config")]
|
||||
use litellm_ai_gateway::python;
|
||||
|
||||
/// Bind to localhost by default so the gateway is not a public, unauthenticated
|
||||
/// provider proxy out of the box. Override with `HOST` (e.g. `0.0.0.0`).
|
||||
|
|
@ -124,10 +124,10 @@ fn resolve_port() -> u16 {
|
|||
fn build_router() -> Router {
|
||||
#[cfg(feature = "python-config")]
|
||||
if let Ok(config_path) = std::env::var("LITELLM_CONFIG_PATH") {
|
||||
match python::config::load_router_from_config(&config_path) {
|
||||
Ok(router) => {
|
||||
match load_model_list(std::path::Path::new(&config_path)) {
|
||||
Ok(deployments) => {
|
||||
eprintln!("loaded model_list from {config_path} via python config reader");
|
||||
return router;
|
||||
return Router::new(deployments);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("config load failed ({err}); falling back to env deployment");
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use litellm_core::providers::azure_ai::ocr::transformation::{
|
|||
AZURE_AI_OCR_CONFIG, AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG,
|
||||
};
|
||||
use litellm_core::providers::mistral::ocr::transformation::MISTRAL_OCR_CONFIG;
|
||||
use litellm_core::providers::reducto::ocr::transformation as reducto;
|
||||
use litellm_core::providers::vertex_ai::ocr::transformation as vertex_ai;
|
||||
use litellm_core::providers::vertex_ai::ocr::transformation::{
|
||||
VERTEX_AI_DEEPSEEK_OCR_CONFIG, VERTEX_AI_OCR_CONFIG,
|
||||
|
|
@ -39,6 +40,7 @@ pub(super) fn ocr_provider_config(
|
|||
) -> Option<&'static dyn OcrProviderConfig> {
|
||||
match provider {
|
||||
"mistral" => Some(&MISTRAL_OCR_CONFIG),
|
||||
"reducto" => reducto::config_for_model(model),
|
||||
"azure_ai" if is_azure_document_intelligence_model(model) => {
|
||||
Some(&AZURE_DOCUMENT_INTELLIGENCE_OCR_CONFIG)
|
||||
}
|
||||
|
|
@ -334,6 +336,7 @@ fn operation_status(response_json: &Value) -> Result<&str, Error> {
|
|||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
pub(super) async fn poll_document_intelligence(
|
||||
operation_url: &str,
|
||||
original_url: &str,
|
||||
|
|
@ -392,9 +395,11 @@ pub(super) async fn poll_document_intelligence(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use litellm_core::ocr::transformation::OcrResponseHandling;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_private_and_metadata_ips() {
|
||||
assert!(is_blocked_ip("127.0.0.1".parse().unwrap()));
|
||||
|
|
@ -438,4 +443,87 @@ mod tests {
|
|||
|
||||
assert_eq!(transformed, document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_error_body_passes_short_strings_through() {
|
||||
let body = "Unauthorized";
|
||||
assert_eq!(truncate_error_body(body), "Unauthorized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_error_body_caps_long_payloads() {
|
||||
let body = "x".repeat(306);
|
||||
let truncated = truncate_error_body(&body);
|
||||
|
||||
assert!(truncated.ends_with("... (truncated)"));
|
||||
let prefix_chars = truncated
|
||||
.strip_suffix("... (truncated)")
|
||||
.expect("truncated marker present")
|
||||
.chars()
|
||||
.count();
|
||||
assert_eq!(prefix_chars, 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_error_body_does_not_split_multibyte_chars() {
|
||||
let body = "é".repeat(266);
|
||||
let truncated = truncate_error_body(&body);
|
||||
assert!(truncated.is_char_boundary(truncated.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocr_dispatch_supports_migrated_providers() {
|
||||
assert!(ocr_provider_config("mistral", "mistral-ocr-latest").is_some());
|
||||
assert!(
|
||||
ocr_provider_config("azure_ai", "pixtral-12b-2409")
|
||||
.expect("azure ai config resolves")
|
||||
.requires_data_uri_document()
|
||||
);
|
||||
assert_eq!(
|
||||
ocr_provider_config("azure_ai", "doc-intelligence/prebuilt-read")
|
||||
.expect("document intelligence config resolves")
|
||||
.response_handling(),
|
||||
OcrResponseHandling::AzureDocumentIntelligencePoll
|
||||
);
|
||||
assert!(
|
||||
ocr_provider_config("vertex_ai", "deepseek-ocr-maas")
|
||||
.expect("vertex deepseek config resolves")
|
||||
.supported_ocr_params()
|
||||
.contains(&"temperature")
|
||||
);
|
||||
assert!(ocr_provider_config("openai", "gpt-4o").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_headers_accepts_string_values() {
|
||||
let headers = json!({
|
||||
"x-trace-id": "trace-1"
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
assert_eq!(
|
||||
string_headers(Some(headers)).expect("string headers accepted"),
|
||||
vec![("x-trace-id".to_string(), "trace-1".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_headers_rejects_non_string_values() {
|
||||
let headers = json!({
|
||||
"x-retry-count": 3
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
let err = string_headers(Some(headers)).expect_err("non-string header rejected");
|
||||
assert_eq!(
|
||||
err,
|
||||
Error::InvalidRequest(
|
||||
"OCR extra_headers.x-retry-count must be a string, got number".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@ pub(crate) async fn execute_ocr_provider_call(
|
|||
.await?;
|
||||
return Ok(request
|
||||
.config
|
||||
.transform_ocr_response(&request.model, response_json)?
|
||||
.transform_ocr_response_with_params(
|
||||
&request.model,
|
||||
response_json,
|
||||
&request.optional_params,
|
||||
)?
|
||||
.into_json());
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +75,10 @@ pub(crate) async fn execute_ocr_provider_call(
|
|||
|
||||
Ok(request
|
||||
.config
|
||||
.transform_ocr_response(&request.model, response_json)?
|
||||
.transform_ocr_response_with_params(
|
||||
&request.model,
|
||||
response_json,
|
||||
&request.optional_params,
|
||||
)?
|
||||
.into_json())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming};
|
||||
use litellm_core::error::Error;
|
||||
use litellm_core::providers::reducto::ocr::transformation::{
|
||||
build_upload_request, extract_document_source, extract_upload_file_id,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use super::common_utils::{convert_document_url_to_data_uri, string_headers};
|
||||
use super::common_utils::{convert_document_url_to_data_uri, string_headers, truncate_error_body};
|
||||
use super::types::{PreparedOcrRequest, ProviderOcrRequest};
|
||||
use crate::client::http_client;
|
||||
use crate::integrations::custom_guardrail::{
|
||||
CustomGuardrailRunner, GuardrailContext, GuardrailError, GuardrailRequest,
|
||||
};
|
||||
|
|
@ -89,22 +93,39 @@ impl OcrLifecycleHooks {
|
|||
)?;
|
||||
let model = request.model.clone();
|
||||
let custom_llm_provider = request.custom_llm_provider.clone();
|
||||
let document = if config.requires_data_uri_document() {
|
||||
let is_reducto = custom_llm_provider == "reducto";
|
||||
let document = if is_reducto {
|
||||
let guarded_document = self
|
||||
.run_during_call_guardrails(&model, &custom_llm_provider, &url, request.document)
|
||||
.await?;
|
||||
upload_reducto_document(
|
||||
&guarded_document,
|
||||
request.api_base.as_deref(),
|
||||
request.timeout,
|
||||
&upstream_headers,
|
||||
)
|
||||
.await?
|
||||
} else if config.requires_data_uri_document() {
|
||||
convert_document_url_to_data_uri(request.document).await?
|
||||
} else {
|
||||
request.document
|
||||
};
|
||||
let optional_params = request.optional_params;
|
||||
let body = config
|
||||
.transform_ocr_request(&request.model, document, request.optional_params)?
|
||||
.transform_ocr_request(&request.model, document, optional_params.clone())?
|
||||
.data;
|
||||
let body = self
|
||||
.run_during_call_guardrails(&model, &custom_llm_provider, &url, body)
|
||||
.await?;
|
||||
let body = if is_reducto {
|
||||
body
|
||||
} else {
|
||||
self.run_during_call_guardrails(&model, &custom_llm_provider, &url, body)
|
||||
.await?
|
||||
};
|
||||
Ok(ProviderOcrRequest {
|
||||
model,
|
||||
config,
|
||||
url,
|
||||
body,
|
||||
optional_params,
|
||||
upstream_headers,
|
||||
timeout: request.timeout,
|
||||
})
|
||||
|
|
@ -165,6 +186,63 @@ impl OcrLifecycleHooks {
|
|||
}
|
||||
}
|
||||
|
||||
async fn upload_reducto_document(
|
||||
document: &Value,
|
||||
api_base: Option<&str>,
|
||||
timeout: Option<std::time::Duration>,
|
||||
upstream_headers: &[(String, String)],
|
||||
) -> Result<Value, Error> {
|
||||
let source = extract_document_source(document)?;
|
||||
let Some(authorization) = upstream_headers
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case("authorization"))
|
||||
.map(|(_, value)| value.as_str())
|
||||
else {
|
||||
return Err(Error::Auth(
|
||||
"Reducto upload requires an Authorization header".to_string(),
|
||||
));
|
||||
};
|
||||
let Some(upload) = build_upload_request(source, authorization, api_base) else {
|
||||
return Ok(document.clone());
|
||||
};
|
||||
let part = reqwest::multipart::Part::bytes(upload.bytes)
|
||||
.file_name(upload.file_name)
|
||||
.mime_str(&upload.mime_type)
|
||||
.map_err(|error| Error::InvalidRequest(error.to_string()))?;
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
let mut request_builder = http_client().post(upload.url).multipart(form);
|
||||
for (name, value) in upstream_headers {
|
||||
if !name.eq_ignore_ascii_case("content-type")
|
||||
&& !name.eq_ignore_ascii_case("content-length")
|
||||
{
|
||||
request_builder = request_builder.header(name, value);
|
||||
}
|
||||
}
|
||||
if let Some(timeout) = timeout {
|
||||
request_builder = request_builder.timeout(timeout);
|
||||
}
|
||||
let response = request_builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| Error::Network(error.to_string()))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| Error::Network(error.to_string()))?;
|
||||
if !status.is_success() {
|
||||
return Err(Error::Http {
|
||||
status: status.as_u16(),
|
||||
body: truncate_error_body(&body),
|
||||
});
|
||||
}
|
||||
let response_json: Value = serde_json::from_str(&body).map_err(|error| {
|
||||
Error::InvalidResponse(format!("invalid Reducto upload response JSON: {error}"))
|
||||
})?;
|
||||
let file_id = extract_upload_file_id(&response_json)?;
|
||||
Ok(json!({"type": "document_url", "document_url": file_id}))
|
||||
}
|
||||
|
||||
impl CallLifecycleHooks<PreparedOcrRequest, PreparedOcrRequest, Value> for OcrLifecycleHooks {
|
||||
type PreCallFuture<'a> = OcrFuture<'a, PreparedOcrRequest>;
|
||||
type DuringCallFuture<'a> = OcrFuture<'a, PreparedOcrRequest>;
|
||||
|
|
|
|||
|
|
@ -24,4 +24,151 @@ pub async fn ocr(request: OcrRequest<'_>) -> Result<Value, Error> {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use serde_json::{Map, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
use super::{OcrRequest, ocr};
|
||||
use crate::integrations::types::RequestMetadata;
|
||||
|
||||
async fn read_http_request(socket: &mut TcpStream) -> String {
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
let header_end = loop {
|
||||
let n = socket.read(&mut buffer).await.expect("reads request");
|
||||
if n == 0 {
|
||||
break request.len();
|
||||
}
|
||||
request.extend_from_slice(&buffer[..n]);
|
||||
if let Some(position) = request.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
break position + 4;
|
||||
}
|
||||
};
|
||||
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().ok())
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
while request.len().saturating_sub(header_end) < content_length {
|
||||
let n = socket.read(&mut buffer).await.expect("reads body");
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..n]);
|
||||
}
|
||||
String::from_utf8(request).expect("request is utf8")
|
||||
}
|
||||
|
||||
fn base_ocr_request(model: &str) -> OcrRequest<'_> {
|
||||
OcrRequest {
|
||||
model,
|
||||
document: json!({
|
||||
"type": "document_url",
|
||||
"document_url": "https://example.com/doc.pdf"
|
||||
}),
|
||||
api_key: Some("sk-test"),
|
||||
api_base: None,
|
||||
custom_llm_provider: None,
|
||||
extra_headers: None,
|
||||
optional_params: Map::new(),
|
||||
timeout: None,
|
||||
callbacks: Vec::new(),
|
||||
guardrails: Vec::new(),
|
||||
request_metadata: RequestMetadata::default(),
|
||||
litellm_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reducto_file_upload_then_parse_maps_response() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("test listener binds");
|
||||
let address = listener.local_addr().expect("listener has local address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut upload_socket, _) = listener.accept().await.expect("accepts upload request");
|
||||
let upload_request = read_http_request(&mut upload_socket).await;
|
||||
let upload_body = r#"{"file_id":"reducto://uploaded.pdf"}"#;
|
||||
let upload_response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
upload_body.len(),
|
||||
upload_body
|
||||
);
|
||||
upload_socket
|
||||
.write_all(upload_response.as_bytes())
|
||||
.await
|
||||
.expect("writes upload response");
|
||||
|
||||
let (mut parse_socket, _) = listener.accept().await.expect("accepts parse request");
|
||||
let parse_request = read_http_request(&mut parse_socket).await;
|
||||
let parse_body = r#"{"job_id":"job_123","usage":{"num_pages":3,"credits":3},"result":{"chunks":[{"content":"Page 1 block A","blocks":[{"content":"Page 1 block A","bbox":{"page":1},"kind":"text"}]},{"content":"Page 2 block A","blocks":[{"content":"Page 2 block A","bbox":{"page":2},"kind":"table"}]},{"content":"Page 1 block B","blocks":[{"content":"Page 1 block B","bbox":{"page":1},"kind":"text"}]},{"content":"Page 3 block A","blocks":[{"content":"Page 3 block A","bbox":{"page":3},"kind":"figure"}]}]}}"#;
|
||||
let parse_response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
parse_body.len(),
|
||||
parse_body
|
||||
);
|
||||
parse_socket
|
||||
.write_all(parse_response.as_bytes())
|
||||
.await
|
||||
.expect("writes parse response");
|
||||
(upload_request, parse_request)
|
||||
});
|
||||
let api_base = format!("http://{address}");
|
||||
let mut request = base_ocr_request("reducto/parse-v3");
|
||||
request.api_base = Some(&api_base);
|
||||
request.api_key = None;
|
||||
request.extra_headers = Some(Map::from_iter([
|
||||
("Authorization".to_string(), json!("Bearer test-key")),
|
||||
("x-trace-id".to_string(), json!("trace-1")),
|
||||
]));
|
||||
request.document = json!({
|
||||
"type": "document_url",
|
||||
"document_url": "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
});
|
||||
request.optional_params = Map::from_iter([
|
||||
(
|
||||
"formatting".to_string(),
|
||||
json!({"table_output_format": "html"}),
|
||||
),
|
||||
("retrieval".to_string(), json!({"chunk_mode": "section"})),
|
||||
("settings".to_string(), json!({"ocr_system": "standard"})),
|
||||
]);
|
||||
|
||||
let response = ocr(request).await.expect("Reducto OCR succeeds");
|
||||
|
||||
assert_eq!(response["pages"].as_array().map(Vec::len), Some(3));
|
||||
assert_eq!(
|
||||
response["pages"][0]["markdown"],
|
||||
"Page 1 block A\n\nPage 1 block B"
|
||||
);
|
||||
assert_eq!(response["pages"][1]["markdown"], "Page 2 block A");
|
||||
assert_eq!(response["pages"][2]["markdown"], "Page 3 block A");
|
||||
assert_eq!(response["usage_info"]["pages_processed"], 3);
|
||||
assert_eq!(response["usage_info"]["credits"], 3);
|
||||
assert_eq!(response["provider_native_response"]["job_id"], "job_123");
|
||||
let (upload_request, parse_request) = server.await.expect("server task completes");
|
||||
assert!(
|
||||
upload_request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer test-key")
|
||||
);
|
||||
assert!(upload_request.contains("application/pdf"));
|
||||
assert!(upload_request.contains("%PDF-1.4"));
|
||||
assert!(upload_request.contains("x-trace-id: trace-1"));
|
||||
assert!(
|
||||
parse_request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer test-key")
|
||||
);
|
||||
assert!(parse_request.contains(r#""input":"reducto://uploaded.pdf""#));
|
||||
assert!(parse_request.contains(r#""table_output_format":"html""#));
|
||||
assert!(parse_request.contains(r#""chunk_mode":"section""#));
|
||||
assert!(parse_request.contains(r#""ocr_system":"standard""#));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use litellm_core::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::common_utils::ocr_provider_config;
|
||||
use super::hooks::OcrLifecycleHooks;
|
||||
|
|
@ -28,17 +29,33 @@ pub(crate) fn prepare_ocr_call(request: OcrRequest<'_>) -> PreparedOcrCall {
|
|||
let model = provider_info.model.to_string();
|
||||
let custom_llm_provider = provider_info.custom_llm_provider.to_string();
|
||||
let config = ocr_provider_config(&custom_llm_provider, &model)
|
||||
.ok_or_else(|| litellm_core::Error::InvalidProvider(custom_llm_provider.clone()));
|
||||
.ok_or_else(|| litellm_core::Error::InvalidProvider(custom_llm_provider.clone()))
|
||||
.and_then(|config| {
|
||||
validate_request_format(config, &request.optional_params, &custom_llm_provider)?;
|
||||
Ok(config)
|
||||
});
|
||||
let optional_params = match &config {
|
||||
Ok(config) => {
|
||||
let supported = config.supported_ocr_params();
|
||||
config.map_ocr_params(
|
||||
let mut mapped = config.map_ocr_params(
|
||||
&request
|
||||
.optional_params
|
||||
.into_iter()
|
||||
.iter()
|
||||
.filter(|(name, _)| supported.contains(&name.as_str()))
|
||||
.map(|(name, value)| (name.clone(), value.clone()))
|
||||
.collect(),
|
||||
)
|
||||
);
|
||||
for name in [
|
||||
"vertex_project",
|
||||
"vertex_ai_project",
|
||||
"vertex_location",
|
||||
"vertex_ai_location",
|
||||
] {
|
||||
if let Some(value) = request.optional_params.get(name) {
|
||||
mapped.insert(name.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
mapped
|
||||
}
|
||||
Err(_) => request.optional_params,
|
||||
};
|
||||
|
|
@ -64,6 +81,26 @@ pub(crate) fn prepare_ocr_call(request: OcrRequest<'_>) -> PreparedOcrCall {
|
|||
}
|
||||
}
|
||||
|
||||
fn validate_request_format(
|
||||
config: &'static dyn litellm_core::ocr::transformation::OcrProviderConfig,
|
||||
optional_params: &Map<String, Value>,
|
||||
provider: &str,
|
||||
) -> Result<(), litellm_core::Error> {
|
||||
let Some(format) = optional_params.get("req_format") else {
|
||||
return Ok(());
|
||||
};
|
||||
match format.as_str() {
|
||||
Some("litellm") => Ok(()),
|
||||
Some("native") if config.supported_ocr_params().contains(&"req_format") => Ok(()),
|
||||
Some("native") => Err(litellm_core::Error::InvalidRequest(format!(
|
||||
"`req_format=native` is not supported for provider {provider}"
|
||||
))),
|
||||
_ => Err(litellm_core::Error::InvalidRequest(format!(
|
||||
"Invalid `req_format`: {format}. Expected `litellm` or `native`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_ocr_call_id() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
let sequence = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
|
|
@ -73,3 +110,54 @@ fn new_ocr_call_id() -> String {
|
|||
.unwrap_or(0);
|
||||
format!("ocr-{timestamp}-{sequence}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use litellm_core::error::Error;
|
||||
use serde_json::{Map, json};
|
||||
|
||||
use super::{OcrRequest, prepare_ocr_call};
|
||||
use crate::integrations::types::RequestMetadata;
|
||||
|
||||
fn base_ocr_request(model: &str) -> OcrRequest<'_> {
|
||||
OcrRequest {
|
||||
model,
|
||||
document: json!({
|
||||
"type": "document_url",
|
||||
"document_url": "https://example.com/doc.pdf"
|
||||
}),
|
||||
api_key: Some("sk-test"),
|
||||
api_base: None,
|
||||
custom_llm_provider: None,
|
||||
extra_headers: None,
|
||||
optional_params: Map::new(),
|
||||
timeout: None,
|
||||
callbacks: Vec::new(),
|
||||
guardrails: Vec::new(),
|
||||
request_metadata: RequestMetadata::default(),
|
||||
litellm_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn request_with_format(format: &str) -> OcrRequest<'_> {
|
||||
let mut request = base_ocr_request("mistral/mistral-ocr-latest");
|
||||
request.optional_params = Map::from_iter([("req_format".to_string(), json!(format))]);
|
||||
request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_format_rejected_for_provider_without_support_as_bad_request() {
|
||||
let prepared = prepare_ocr_call(request_with_format("native"));
|
||||
assert!(
|
||||
matches!(prepared.request.config, Err(Error::InvalidRequest(message)) if message.contains("not supported for provider"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_format_rejected_for_provider_without_support_as_bad_request() {
|
||||
let prepared = prepare_ocr_call(request_with_format("raw"));
|
||||
assert!(
|
||||
matches!(prepared.request.config, Err(Error::InvalidRequest(message)) if message.contains("Invalid `req_format`"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ pub(crate) struct ProviderOcrRequest {
|
|||
pub(crate) config: &'static dyn OcrProviderConfig,
|
||||
pub(crate) url: String,
|
||||
pub(crate) body: Value,
|
||||
pub(crate) optional_params: Map<String, Value>,
|
||||
pub(crate) upstream_headers: Vec<(String, String)>,
|
||||
pub(crate) timeout: Option<Duration>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
# ai-gateway/src/python — Python interop (load-time only)
|
||||
|
||||
Functions here embed the Python interpreter (pyo3) and take the GIL to call into
|
||||
`litellm` (e.g. read the proxy `model_list`). Compiled only under the
|
||||
`python-config` feature.
|
||||
|
||||
## Hard rule: non-hot-path functions only
|
||||
|
||||
Everything in this folder MUST run **at most once per process lifetime — at
|
||||
startup / load time** (config read, warm-up). NEVER call into Python on the
|
||||
request path:
|
||||
|
||||
- No GIL acquisition per request, per connection, or per realtime event.
|
||||
- No Python call inside a route handler, the router's hot path, or any loop that
|
||||
scales with traffic.
|
||||
|
||||
**Why:** the GIL serializes execution and would cap throughput; the realtime data
|
||||
path must stay pure Rust. Every acquisition is recorded by `crate::gil` — poll
|
||||
`GET /health/gil`, and `total_acquisitions` MUST stay flat under load.
|
||||
|
||||
## How to add one
|
||||
|
||||
Resolve whatever Python-derived data you need **once at boot** and hand the rest
|
||||
of the gateway an owned, plain-Rust value (e.g. build a `Router` from the
|
||||
resolved `model_list`). Record the acquisition via `crate::gil::record_acquisition()`
|
||||
immediately before taking the GIL. If a function would need to run per request,
|
||||
it does not belong here — move the work to Rust, or pre-resolve it at startup.
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
//! Build the router by calling the Python proxy config reader (load time only).
|
||||
//!
|
||||
//! Embeds the interpreter via pyo3 and calls
|
||||
//! `litellm.proxy.read_model_list.read_model_list`, which reuses the proxy's
|
||||
//! `os.environ/` + secret-manager resolution. The GIL is taken **once at boot**
|
||||
//! (and recorded in [`crate::gil`]); the realtime hot path never touches Python.
|
||||
//!
|
||||
//! Compiled only under the `python-config` feature.
|
||||
use litellm_core::error::Error;
|
||||
use litellm_core::router::{Deployment, Router};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use crate::gil;
|
||||
|
||||
/// Load the router's `model_list` from `config_path` via the Python reader.
|
||||
pub fn load_router_from_config(config_path: &str) -> Result<Router, Error> {
|
||||
gil::record_acquisition();
|
||||
Python::attach(|py| {
|
||||
let model_list = py
|
||||
.import("litellm.proxy.read_model_list")
|
||||
.and_then(|module| module.getattr("read_model_list"))
|
||||
.and_then(|reader| reader.call1((config_path,)))
|
||||
.map_err(|err| Error::Routing(format!("read_model_list failed: {err}")))?;
|
||||
|
||||
let model_list_json: String = py
|
||||
.import("json")
|
||||
.and_then(|json| json.getattr("dumps"))
|
||||
.and_then(|dumps| dumps.call1((model_list,)))
|
||||
.and_then(|encoded| encoded.extract())
|
||||
.map_err(|err| Error::Routing(format!("serializing model_list failed: {err}")))?;
|
||||
|
||||
let deployments: Vec<Deployment> = serde_json::from_str(&model_list_json)
|
||||
.map_err(|err| Error::Routing(format!("parsing model_list failed: {err}")))?;
|
||||
|
||||
Ok(Router::new(deployments))
|
||||
})
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
//! Python interop for the gateway. See `AGENTS.md`: **load-time / non-hot-path
|
||||
//! only.** Compiled only under the `python-config` feature.
|
||||
|
||||
pub mod config;
|
||||
|
|
@ -13,7 +13,7 @@ private). This is the norm — don't split until it hurts.
|
|||
pub fn router() -> Router<AppState> { Router::new().route(PATH, get(handle)) }
|
||||
async fn handle(...) -> impl IntoResponse { ... }
|
||||
```
|
||||
`health.rs` and `gil.rs` are examples.
|
||||
`health.rs` is the example.
|
||||
|
||||
## Split out `service` when there's real logic
|
||||
When a route has business logic worth testing without axum, put it in a sibling
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
//! `GET /health/gil` — poll to confirm Python is only touched at load time.
|
||||
//! Simple-route template: a `router()` plus its handler, in one file.
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::gil;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// This route's contribution to the app router.
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/health/gil", get(status))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GilStatusResponse {
|
||||
gil_acquired_last_30s: bool,
|
||||
total_acquisitions: u64,
|
||||
seconds_since_last: Option<u64>,
|
||||
}
|
||||
|
||||
async fn status() -> Json<GilStatusResponse> {
|
||||
let snapshot = gil::snapshot();
|
||||
Json(GilStatusResponse {
|
||||
gil_acquired_last_30s: snapshot.acquired_last_30s,
|
||||
total_acquisitions: snapshot.total_acquisitions,
|
||||
seconds_since_last: snapshot.seconds_since_last,
|
||||
})
|
||||
}
|
||||
|
|
@ -21,6 +21,12 @@ pub fn router() -> Router<AppState> {
|
|||
Router::new().route(MESSAGES_ROUTE_PATH, post(handle))
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "messages_gateway_route",
|
||||
target = "litellm::function_trace",
|
||||
level = "trace",
|
||||
skip_all
|
||||
)]
|
||||
async fn handle(
|
||||
_auth: RequireMasterKey,
|
||||
State(state): State<AppState>,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ pub(crate) enum MessagesResponse {
|
|||
Stream(reqwest::Response),
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "messages_gateway_service",
|
||||
target = "litellm::function_trace",
|
||||
level = "trace",
|
||||
skip_all
|
||||
)]
|
||||
pub async fn run(
|
||||
router: &Arc<Router>,
|
||||
body: Value,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@
|
|||
//!
|
||||
//! **Template:** every route module exposes `pub fn router() -> Router<AppState>`
|
||||
//! that mounts its own paths; [`app`] merges them. A trivial route is a single
|
||||
//! file (`health.rs`, `gil.rs`); a non-trivial one is a folder (`realtime/`) with
|
||||
//! file (`health.rs`); a non-trivial one is a folder (`realtime/`) with
|
||||
//! `handler` (entry) + `service` (logic) + `transport` (adapters). See AGENTS.md.
|
||||
|
||||
pub mod gil;
|
||||
pub mod health;
|
||||
pub mod messages;
|
||||
pub mod realtime;
|
||||
|
|
@ -19,7 +18,6 @@ use crate::state::AppState;
|
|||
pub fn app(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.merge(health::router())
|
||||
.merge(gil::router())
|
||||
.merge(messages::router())
|
||||
.merge(realtime::router())
|
||||
.merge(responses::router())
|
||||
|
|
|
|||
65
litellm-rust/crates/ai-gateway/src/trace_parity.rs
Normal file
65
litellm-rust/crates/ai-gateway/src/trace_parity.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//! Harness-only in-process adapters. Never mounted as production routes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::header::{AUTHORIZATION, CONTENT_TYPE};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use litellm_core::Error;
|
||||
use litellm_core::router::{Deployment, LiteLLMParams, Router as ModelRouter};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::io::realtime_pool::RealtimePool;
|
||||
use crate::routes;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GatewayResponse {
|
||||
pub status: u16,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
pub async fn messages_request(
|
||||
model_alias: String,
|
||||
provider_model: String,
|
||||
api_base: String,
|
||||
body: Value,
|
||||
) -> Result<GatewayResponse, Error> {
|
||||
let state = AppState {
|
||||
router: Arc::new(ModelRouter::new(vec![Deployment {
|
||||
model_name: model_alias,
|
||||
litellm_params: LiteLLMParams {
|
||||
model: provider_model,
|
||||
api_key: Some("trace-provider-key".to_string()),
|
||||
api_base: Some(api_base),
|
||||
},
|
||||
}])),
|
||||
master_key: Some(Arc::from("trace-master-key")),
|
||||
loggers: Arc::new(Vec::new()),
|
||||
realtime_pool: RealtimePool::disabled(),
|
||||
};
|
||||
let request = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/messages")
|
||||
.header(AUTHORIZATION, "Bearer trace-master-key")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.map_err(|error| Error::InvalidRequest(error.to_string()))?;
|
||||
let response = routes::app(state)
|
||||
.oneshot(request)
|
||||
.await
|
||||
.map_err(|error| match error {})?;
|
||||
let status: StatusCode = response.status();
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.map_err(|error| Error::InvalidResponse(error.to_string()))?;
|
||||
let body = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
Error::InvalidResponse(format!("gateway returned invalid JSON: {error}"))
|
||||
})?;
|
||||
Ok(GatewayResponse {
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,23 +1,19 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use litellm_core::error::Error;
|
||||
use litellm_core::http_utils::has_header;
|
||||
use litellm_core::ocr::transformation::OcrResponseHandling;
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
use super::common_utils::{ocr_provider_config, string_headers, truncate_error_body};
|
||||
use super::{OcrRequest, ocr};
|
||||
use crate::integrations::custom_guardrail::{
|
||||
use litellm_ai_gateway::integrations::custom_guardrail::{
|
||||
CustomGuardrail, GuardrailContext, GuardrailDecision, GuardrailError, GuardrailEventHook,
|
||||
GuardrailFuture, GuardrailRequest,
|
||||
};
|
||||
use crate::integrations::custom_logger::{
|
||||
use litellm_ai_gateway::integrations::custom_logger::{
|
||||
CallbackTiming, CallbackValue, CustomLogger, LogFuture, ModelCallDetails,
|
||||
};
|
||||
use crate::integrations::types::RequestMetadata;
|
||||
use litellm_ai_gateway::integrations::types::RequestMetadata;
|
||||
use litellm_ai_gateway::ocr::{OcrRequest, ocr};
|
||||
use litellm_core::error::Error;
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
async fn read_http_headers(socket: &mut TcpStream) -> String {
|
||||
let mut request = Vec::new();
|
||||
|
|
@ -136,6 +132,7 @@ struct RecordingOcrGuardrail {
|
|||
hooks: Vec<GuardrailEventHook>,
|
||||
events: Mutex<Vec<&'static str>>,
|
||||
block_pre_call: bool,
|
||||
block_during_call: bool,
|
||||
}
|
||||
|
||||
impl RecordingOcrGuardrail {
|
||||
|
|
@ -144,6 +141,7 @@ impl RecordingOcrGuardrail {
|
|||
hooks,
|
||||
events: Mutex::new(Vec::new()),
|
||||
block_pre_call: false,
|
||||
block_during_call: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +150,16 @@ impl RecordingOcrGuardrail {
|
|||
hooks: vec![GuardrailEventHook::PreCall],
|
||||
events: Mutex::new(Vec::new()),
|
||||
block_pre_call: true,
|
||||
block_during_call: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn blocking_during_call() -> Self {
|
||||
Self {
|
||||
hooks: vec![GuardrailEventHook::DuringCall],
|
||||
events: Mutex::new(Vec::new()),
|
||||
block_pre_call: false,
|
||||
block_during_call: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,91 +201,95 @@ impl CustomGuardrail for RecordingOcrGuardrail {
|
|||
) -> GuardrailFuture<'a> {
|
||||
Box::pin(async move {
|
||||
self.events.lock().unwrap().push("async_moderation_hook");
|
||||
if self.block_during_call {
|
||||
return Ok(GuardrailDecision::Block(GuardrailError::blocked(
|
||||
"blocked before provider",
|
||||
)));
|
||||
}
|
||||
request.data["body"]["guarded_during"] = json!(true);
|
||||
Ok(GuardrailDecision::Mask(request))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_error_body_passes_short_strings_through() {
|
||||
let body = "Unauthorized";
|
||||
assert_eq!(truncate_error_body(body), "Unauthorized");
|
||||
fn base_ocr_request(model: &str) -> OcrRequest<'_> {
|
||||
OcrRequest {
|
||||
model,
|
||||
document: json!({
|
||||
"type": "document_url",
|
||||
"document_url": "https://example.com/doc.pdf"
|
||||
}),
|
||||
api_key: Some("sk-test"),
|
||||
api_base: None,
|
||||
custom_llm_provider: None,
|
||||
extra_headers: None,
|
||||
optional_params: Map::new(),
|
||||
timeout: None,
|
||||
callbacks: Vec::new(),
|
||||
guardrails: Vec::new(),
|
||||
request_metadata: RequestMetadata::default(),
|
||||
litellm_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_error_body_caps_long_payloads() {
|
||||
let body = "x".repeat(306);
|
||||
let truncated = truncate_error_body(&body);
|
||||
#[tokio::test]
|
||||
async fn reducto_during_call_guardrail_blocks_before_upload() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("test listener binds");
|
||||
let address = listener.local_addr().expect("listener has local address");
|
||||
let api_base = format!("http://{address}");
|
||||
let guardrail = Arc::new(RecordingOcrGuardrail::blocking_during_call());
|
||||
let mut request = base_ocr_request("reducto/parse-v3");
|
||||
request.api_base = Some(&api_base);
|
||||
request.document = json!({
|
||||
"type": "document_url",
|
||||
"document_url": "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
});
|
||||
request.guardrails = vec![guardrail.clone()];
|
||||
|
||||
assert!(truncated.ends_with("... (truncated)"));
|
||||
let prefix_chars = truncated
|
||||
.strip_suffix("... (truncated)")
|
||||
.expect("truncated marker present")
|
||||
.chars()
|
||||
.count();
|
||||
assert_eq!(prefix_chars, 256);
|
||||
let error = ocr(request).await.expect_err("guardrail blocks upload");
|
||||
|
||||
assert!(matches!(error, Error::InvalidRequest(_)));
|
||||
assert_eq!(guardrail.events(), vec!["async_moderation_hook"]);
|
||||
let accepted = tokio::time::timeout(Duration::from_millis(100), listener.accept()).await;
|
||||
assert!(accepted.is_err(), "upload socket should not be touched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_error_body_does_not_split_multibyte_chars() {
|
||||
let body = "é".repeat(266);
|
||||
let truncated = truncate_error_body(&body);
|
||||
assert!(truncated.is_char_boundary(truncated.len()));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn reducto_upload_error_body_is_truncated() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("test listener binds");
|
||||
let address = listener.local_addr().expect("listener has local address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("accepts upload request");
|
||||
let _request = read_http_request(&mut socket).await;
|
||||
let body = "x".repeat(300);
|
||||
let response = format!(
|
||||
"HTTP/1.1 500 Internal Server Error\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
socket
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("writes upload response");
|
||||
});
|
||||
let api_base = format!("http://{address}");
|
||||
let mut request = base_ocr_request("reducto/parse-v3");
|
||||
request.api_base = Some(&api_base);
|
||||
request.document = json!({
|
||||
"type": "document_url",
|
||||
"document_url": "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
});
|
||||
|
||||
let error = ocr(request).await.expect_err("upload should fail");
|
||||
|
||||
#[test]
|
||||
fn ocr_dispatch_supports_migrated_providers() {
|
||||
assert!(ocr_provider_config("mistral", "mistral-ocr-latest").is_some());
|
||||
assert!(
|
||||
ocr_provider_config("azure_ai", "pixtral-12b-2409")
|
||||
.expect("azure ai config resolves")
|
||||
.requires_data_uri_document()
|
||||
matches!(error, Error::Http { status: 500, body } if body.chars().count() < 300 && body.ends_with("... (truncated)"))
|
||||
);
|
||||
assert_eq!(
|
||||
ocr_provider_config("azure_ai", "doc-intelligence/prebuilt-read")
|
||||
.expect("document intelligence config resolves")
|
||||
.response_handling(),
|
||||
OcrResponseHandling::AzureDocumentIntelligencePoll
|
||||
);
|
||||
assert!(
|
||||
ocr_provider_config("vertex_ai", "deepseek-ocr-maas")
|
||||
.expect("vertex deepseek config resolves")
|
||||
.supported_ocr_params()
|
||||
.contains(&"temperature")
|
||||
);
|
||||
assert!(ocr_provider_config("openai", "gpt-4o").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_headers_accepts_string_values() {
|
||||
let headers = json!({
|
||||
"x-trace-id": "trace-1"
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
assert_eq!(
|
||||
string_headers(Some(headers)).expect("string headers accepted"),
|
||||
vec![("x-trace-id".to_string(), "trace-1".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_header_detection_is_case_insensitive() {
|
||||
let headers = vec![
|
||||
("x-trace-id".to_string(), "trace-1".to_string()),
|
||||
("authorization".to_string(), "Bearer sk-test".to_string()),
|
||||
];
|
||||
|
||||
assert!(has_header(&headers, "authorization"));
|
||||
|
||||
let headers = vec![("Authorization".to_string(), "Bearer sk-test".to_string())];
|
||||
assert!(has_header(&headers, "authorization"));
|
||||
|
||||
let headers = vec![("x-trace-id".to_string(), "trace-1".to_string())];
|
||||
assert!(!has_header(&headers, "authorization"));
|
||||
server.await.expect("server task completes");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -595,21 +607,3 @@ async fn document_intelligence_poll_uses_resolved_subscription_key() {
|
|||
"{poll_request}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_headers_rejects_non_string_values() {
|
||||
let headers = json!({
|
||||
"x-retry-count": 3
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
let err = string_headers(Some(headers)).expect_err("non-string header rejected");
|
||||
assert_eq!(
|
||||
err,
|
||||
Error::InvalidRequest(
|
||||
"OCR extra_headers.x-retry-count must be a string, got number".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
16
litellm-rust/crates/config/Cargo.toml
Normal file
16
litellm-rust/crates/config/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "litellm-config"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
litellm-core.workspace = true
|
||||
pyo3 = { workspace = true, features = ["auto-initialize"], optional = true }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
python = ["dep:pyo3"]
|
||||
11
litellm-rust/crates/config/src/error.rs
Normal file
11
litellm-rust/crates/config/src/error.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
use thiserror::Error as ThisError;
|
||||
|
||||
#[derive(Debug, ThisError)]
|
||||
pub enum Error {
|
||||
#[error("read_model_list failed: {0}")]
|
||||
PythonLoading(String),
|
||||
#[error("serializing model_list failed: {0}")]
|
||||
Serialization(String),
|
||||
#[error("parsing model_list failed: {0}")]
|
||||
ModelListParsing(#[source] serde_json::Error),
|
||||
}
|
||||
7
litellm-rust/crates/config/src/lib.rs
Normal file
7
litellm-rust/crates/config/src/lib.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod error;
|
||||
#[cfg(feature = "python")]
|
||||
mod python;
|
||||
|
||||
pub use error::Error;
|
||||
#[cfg(feature = "python")]
|
||||
pub use python::load_model_list;
|
||||
76
litellm-rust/crates/config/src/python.rs
Normal file
76
litellm-rust/crates/config/src/python.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
use std::path::Path;
|
||||
|
||||
use litellm_core::router::Deployment;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
pub fn load_model_list(config_path: &Path) -> Result<Vec<Deployment>, Error> {
|
||||
Python::attach(|python| {
|
||||
let model_list = python
|
||||
.import("litellm.proxy.read_model_list")
|
||||
.and_then(|module| module.getattr("read_model_list"))
|
||||
.and_then(|reader| reader.call1((config_path.to_string_lossy().as_ref(),)))
|
||||
.map_err(|error| Error::PythonLoading(error.to_string()))?;
|
||||
|
||||
let model_list_json = python
|
||||
.import("json")
|
||||
.and_then(|json| json.getattr("dumps"))
|
||||
.and_then(|dumps| dumps.call1((model_list,)))
|
||||
.and_then(|encoded| encoded.extract::<String>())
|
||||
.map_err(|error| Error::Serialization(error.to_string()))?;
|
||||
|
||||
parse_model_list(&model_list_json)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_model_list(model_list_json: &str) -> Result<Vec<Deployment>, Error> {
|
||||
serde_json::from_str(model_list_json).map_err(Error::ModelListParsing)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_resolved_model_list() {
|
||||
let deployments = parse_model_list(
|
||||
r#"[
|
||||
{
|
||||
"model_name": "realtime",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-realtime",
|
||||
"api_key": "resolved-secret",
|
||||
"api_base": "https://api.example.test/v1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model_name": "without-optional-values",
|
||||
"litellm_params": {"model": "openai/gpt-4.1"}
|
||||
}
|
||||
]"#,
|
||||
)
|
||||
.expect("resolved model list should parse");
|
||||
|
||||
assert_eq!(deployments.len(), 2);
|
||||
assert_eq!(deployments[0].model_name, "realtime");
|
||||
assert_eq!(
|
||||
deployments[0].litellm_params.api_key.as_deref(),
|
||||
Some("resolved-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
deployments[0].litellm_params.api_base.as_deref(),
|
||||
Some("https://api.example.test/v1")
|
||||
);
|
||||
assert_eq!(deployments[1].litellm_params.api_key, None);
|
||||
assert_eq!(deployments[1].litellm_params.api_base, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_model_list_returns_parsing_error() {
|
||||
let error = parse_model_list(r#"[{"model_name":"missing-params"}]"#)
|
||||
.expect_err("missing litellm_params should fail");
|
||||
|
||||
assert!(matches!(error, Error::ModelListParsing(_)));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,12 +6,14 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
rand.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { workspace = true, optional = true }
|
||||
sha2.workspace = true
|
||||
aws-config = { version = "1.9.0", default-features = false, features = ["rustls", "rt-tokio"], optional = true }
|
||||
aws-credential-types = { version = "1.3.0", features = ["hardcoded-credentials"], optional = true }
|
||||
|
|
@ -30,6 +32,9 @@ bedrock-auth = [
|
|||
"dep:aws-types",
|
||||
"dep:aws-smithy-runtime-api",
|
||||
]
|
||||
observability = ["dep:tracing-subscriber"]
|
||||
|
||||
[dev-dependencies]
|
||||
rstest.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
|
|
@ -41,3 +41,5 @@ pub const CHAT_COMPLETION_OBJECT: &str = "chat.completion";
|
|||
/// `litellm/litellm_core_utils/prompt_templates/factory.py`.
|
||||
pub const EMPTY_TEXT_PLACEHOLDER: &str =
|
||||
"[System: Empty message content sanitised to satisfy protocol]";
|
||||
|
||||
pub const FUNCTION_TRACE_TARGET: &str = "litellm::function_trace";
|
||||
|
|
|
|||
|
|
@ -101,6 +101,23 @@ mod tests {
|
|||
assert!(!has_header(&headers, "authorization"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_header_detection_is_case_insensitive() {
|
||||
let headers = vec![
|
||||
("x-trace-id".to_string(), "trace-1".to_string()),
|
||||
("authorization".to_string(), "Bearer sk-test".to_string()),
|
||||
];
|
||||
|
||||
assert!(has_header(&headers, "authorization"));
|
||||
|
||||
let headers = vec![("Authorization".to_string(), "Bearer sk-test".to_string())];
|
||||
|
||||
assert!(has_header(&headers, "authorization"));
|
||||
|
||||
let headers = vec![("x-trace-id".to_string(), "trace-1".to_string())];
|
||||
assert!(!has_header(&headers, "authorization"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_detection_requires_a_non_empty_token() {
|
||||
assert!(has_bearer_auth(&[(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ pub mod constants;
|
|||
pub mod error;
|
||||
pub mod http_utils;
|
||||
pub mod messages;
|
||||
#[cfg(any(feature = "observability", test))]
|
||||
pub mod observability;
|
||||
pub mod ocr;
|
||||
pub mod providers;
|
||||
pub mod realtime;
|
||||
|
|
|
|||
215
litellm-rust/crates/core/src/observability/function_trace.rs
Normal file
215
litellm-rust/crates/core/src/observability/function_trace.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::Serialize;
|
||||
use tracing::span::{Attributes, Id};
|
||||
use tracing::{Dispatch, Subscriber};
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
use tracing_subscriber::{Layer, Registry};
|
||||
|
||||
use super::function_trace_filter;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct FunctionTraceEvent {
|
||||
pub id: usize,
|
||||
pub parent_id: Option<usize>,
|
||||
pub function: &'static str,
|
||||
pub module_path: Option<&'static str>,
|
||||
pub file: Option<&'static str>,
|
||||
pub line: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FunctionTrace {
|
||||
events: Arc<Mutex<Vec<FunctionTraceEvent>>>,
|
||||
span_events: Arc<Mutex<HashMap<Id, usize>>>,
|
||||
}
|
||||
|
||||
impl FunctionTrace {
|
||||
pub fn dispatcher(&self) -> Dispatch {
|
||||
Dispatch::new(
|
||||
Registry::default().with(
|
||||
FunctionTraceLayer {
|
||||
trace: self.clone(),
|
||||
}
|
||||
.with_filter(function_trace_filter()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn events(&self) -> Vec<FunctionTraceEvent> {
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct FunctionTraceLayer {
|
||||
trace: FunctionTrace,
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for FunctionTraceLayer
|
||||
where
|
||||
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
|
||||
{
|
||||
fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, S>) {
|
||||
let parent_id = context.span(id).and_then(|span| {
|
||||
let span_events = self
|
||||
.trace
|
||||
.span_events
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
span.scope()
|
||||
.skip(1)
|
||||
.find_map(|ancestor| span_events.get(&ancestor.id()).copied())
|
||||
});
|
||||
let mut events = self
|
||||
.trace
|
||||
.events
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let event_id = events.len();
|
||||
events.push(FunctionTraceEvent {
|
||||
id: event_id,
|
||||
parent_id,
|
||||
function: attributes.metadata().name(),
|
||||
module_path: attributes.metadata().module_path(),
|
||||
file: attributes.metadata().file(),
|
||||
line: attributes.metadata().line(),
|
||||
});
|
||||
self.trace
|
||||
.span_events
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.insert(id.clone(), event_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::constants::FUNCTION_TRACE_TARGET;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn event(
|
||||
id: usize,
|
||||
parent_id: Option<usize>,
|
||||
function: &'static str,
|
||||
) -> (usize, Option<usize>, &'static str) {
|
||||
(id, parent_id, function)
|
||||
}
|
||||
|
||||
fn structural_events(
|
||||
events: &[FunctionTraceEvent],
|
||||
) -> Vec<(usize, Option<usize>, &'static str)> {
|
||||
events
|
||||
.iter()
|
||||
.map(|event| (event.id, event.parent_id, event.function))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
async fn outer() {
|
||||
tokio::task::yield_now().await;
|
||||
inner().await;
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
async fn inner() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
async fn concurrent_parent() {
|
||||
tokio::join!(inner(), inner());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_futures_keep_separate_traces_across_yields() {
|
||||
use tracing::instrument::WithSubscriber;
|
||||
|
||||
let first = FunctionTrace::default();
|
||||
let second = FunctionTrace::default();
|
||||
let outside = FunctionTrace::default();
|
||||
|
||||
async {
|
||||
tokio::join!(
|
||||
outer().with_subscriber(first.dispatcher()),
|
||||
inner().with_subscriber(second.dispatcher()),
|
||||
);
|
||||
inner().await;
|
||||
}
|
||||
.with_subscriber(outside.dispatcher())
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
structural_events(&first.events()),
|
||||
vec![event(0, None, "outer"), event(1, Some(0), "inner")],
|
||||
);
|
||||
assert_eq!(
|
||||
structural_events(&second.events()),
|
||||
vec![event(0, None, "inner")],
|
||||
);
|
||||
assert_eq!(
|
||||
structural_events(&outside.events()),
|
||||
vec![event(0, None, "inner")],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_siblings_keep_the_same_parent() {
|
||||
use tracing::instrument::WithSubscriber;
|
||||
|
||||
let trace = FunctionTrace::default();
|
||||
concurrent_parent()
|
||||
.with_subscriber(trace.dispatcher())
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
structural_events(&trace.events()),
|
||||
vec![
|
||||
event(0, None, "concurrent_parent"),
|
||||
event(1, Some(0), "inner"),
|
||||
event(2, Some(0), "inner"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_matching_spans_in_creation_order() {
|
||||
let trace = FunctionTrace::default();
|
||||
let dispatch = trace.dispatcher();
|
||||
|
||||
tracing::dispatcher::with_default(&dispatch, || {
|
||||
let _ignored = tracing::trace_span!(target: "other", "ignored");
|
||||
let _first = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name");
|
||||
let _wrong_level = tracing::debug_span!(target: FUNCTION_TRACE_TARGET, "wrong_level");
|
||||
let _second = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name");
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
structural_events(&trace.events()),
|
||||
vec![event(0, None, "same_name"), event(1, None, "same_name")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_matching_span_nesting_depth() {
|
||||
let trace = FunctionTrace::default();
|
||||
let dispatch = trace.dispatcher();
|
||||
|
||||
tracing::dispatcher::with_default(&dispatch, || {
|
||||
let outer = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "outer");
|
||||
let _outer_guard = outer.enter();
|
||||
let _inner = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "inner");
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
structural_events(&trace.events()),
|
||||
vec![event(0, None, "outer"), event(1, Some(0), "inner")]
|
||||
);
|
||||
}
|
||||
}
|
||||
59
litellm-rust/crates/core/src/observability/mod.rs
Normal file
59
litellm-rust/crates/core/src/observability/mod.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
use tracing::span::Id;
|
||||
use tracing::{Level, Metadata, Subscriber};
|
||||
use tracing_subscriber::filter::{FilterFn, LevelFilter, filter_fn};
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
use crate::constants::FUNCTION_TRACE_TARGET;
|
||||
|
||||
pub mod function_trace;
|
||||
|
||||
pub use function_trace::{FunctionTrace, FunctionTraceEvent};
|
||||
|
||||
pub fn function_trace_filter() -> FilterFn<impl Fn(&Metadata<'_>) -> bool> {
|
||||
filter_fn(|metadata| {
|
||||
metadata.is_span()
|
||||
&& metadata.target() == FUNCTION_TRACE_TARGET
|
||||
&& *metadata.level() == Level::TRACE
|
||||
})
|
||||
.with_max_level_hint(LevelFilter::TRACE)
|
||||
}
|
||||
|
||||
pub fn span_depth<S>(context: &Context<'_, S>, id: &Id) -> usize
|
||||
where
|
||||
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
|
||||
{
|
||||
context
|
||||
.span(id)
|
||||
.map(|span| span.scope().skip(1).count())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tracing::instrument::WithSubscriber;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
async fn instrumented_with_literal_target() {}
|
||||
|
||||
#[tokio::test]
|
||||
async fn literal_instrument_target_matches_filter_constant() {
|
||||
assert_eq!(FUNCTION_TRACE_TARGET, "litellm::function_trace");
|
||||
|
||||
let trace = FunctionTrace::default();
|
||||
instrumented_with_literal_target()
|
||||
.with_subscriber(trace.dispatcher())
|
||||
.await;
|
||||
|
||||
let events = trace.events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].id, 0);
|
||||
assert_eq!(events[0].parent_id, None);
|
||||
assert_eq!(events[0].function, "instrumented_with_literal_target");
|
||||
assert_eq!(events[0].module_path, Some(module_path!()));
|
||||
assert_eq!(events[0].file, Some(file!()));
|
||||
assert!(events[0].line.is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,15 @@ pub trait OcrProviderConfig: Sync {
|
|||
response_json: Value,
|
||||
) -> Result<OcrResponseData, Error>;
|
||||
|
||||
fn transform_ocr_response_with_params(
|
||||
&self,
|
||||
model: &str,
|
||||
response_json: Value,
|
||||
_optional_params: &Map<String, Value>,
|
||||
) -> Result<OcrResponseData, Error> {
|
||||
self.transform_ocr_response(model, response_json)
|
||||
}
|
||||
|
||||
fn complete_url(
|
||||
&self,
|
||||
api_base: Option<&str>,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OcrRequestData {
|
||||
|
|
@ -14,16 +14,25 @@ pub struct OcrResponseData {
|
|||
pub document_annotation: Option<Value>,
|
||||
pub usage_info: Option<Value>,
|
||||
pub object: String,
|
||||
pub extra_fields: Map<String, Value>,
|
||||
pub provider_native_response: Option<Value>,
|
||||
}
|
||||
|
||||
impl OcrResponseData {
|
||||
pub fn into_json(self) -> Value {
|
||||
serde_json::json!({
|
||||
let mut response = serde_json::json!({
|
||||
"pages": self.pages,
|
||||
"model": self.model,
|
||||
"document_annotation": self.document_annotation,
|
||||
"usage_info": self.usage_info,
|
||||
"object": self.object,
|
||||
})
|
||||
});
|
||||
if let Value::Object(object) = &mut response {
|
||||
object.extend(self.extra_fields);
|
||||
if let Some(native_response) = self.provider_native_response {
|
||||
object.insert("provider_native_response".to_string(), native_response);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -134,6 +134,8 @@ impl OcrProviderConfig for MistralOcrConfig {
|
|||
document_annotation,
|
||||
usage_info,
|
||||
object: "ocr".to_string(),
|
||||
extra_fields: Map::new(),
|
||||
provider_native_response: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ pub mod azure_ai;
|
|||
pub mod bedrock;
|
||||
pub mod mistral;
|
||||
pub mod openai;
|
||||
pub mod reducto;
|
||||
pub mod vertex_ai;
|
||||
|
|
|
|||
1
litellm-rust/crates/core/src/providers/reducto/mod.rs
Normal file
1
litellm-rust/crates/core/src/providers/reducto/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod ocr;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
pub mod transformation;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
202
litellm-rust/crates/core/src/providers/reducto/ocr/tests.rs
Normal file
202
litellm-rust/crates/core/src/providers/reducto/ocr/tests.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
use rstest::{fixture, rstest};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::transformation::*;
|
||||
use crate::ocr::transformation::OcrProviderConfig;
|
||||
|
||||
#[fixture]
|
||||
fn parse_response() -> Value {
|
||||
json!({
|
||||
"job_id": "job_123",
|
||||
"usage": {"num_pages": 3, "credits": 3},
|
||||
"result": {
|
||||
"chunks": [
|
||||
{
|
||||
"content": "Page 1 block A",
|
||||
"blocks": [{
|
||||
"content": "Page 1 block A",
|
||||
"bbox": {"page": 1},
|
||||
"kind": "text",
|
||||
}],
|
||||
},
|
||||
{
|
||||
"content": "Page 2 block A",
|
||||
"blocks": [{
|
||||
"content": "Page 2 block A",
|
||||
"bbox": {"page": 2},
|
||||
"kind": "table",
|
||||
}],
|
||||
},
|
||||
{
|
||||
"content": "Page 1 block B",
|
||||
"blocks": [{
|
||||
"content": "Page 1 block B",
|
||||
"bbox": {"page": 1},
|
||||
"kind": "text",
|
||||
}],
|
||||
},
|
||||
{
|
||||
"content": "Page 3 block A",
|
||||
"blocks": [{
|
||||
"content": "Page 3 block A",
|
||||
"bbox": {"page": 3},
|
||||
"kind": "figure",
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_parse_v3_file_upload_and_response_mapping(parse_response: Value) {
|
||||
let source = classify_document_source("data:application/pdf;base64,JVBERi0xLjQ=")
|
||||
.expect("PDF data URI should be valid");
|
||||
let upload = build_upload_request(
|
||||
source,
|
||||
"Bearer test-key",
|
||||
Some("https://platform.reducto.ai"),
|
||||
)
|
||||
.expect("data URI should require upload");
|
||||
assert_eq!(upload.url, "https://platform.reducto.ai/upload");
|
||||
assert_eq!(upload.authorization, "Bearer test-key");
|
||||
assert_eq!(upload.file_name, "document");
|
||||
assert_eq!(upload.mime_type, "application/pdf");
|
||||
assert_eq!(upload.bytes, b"%PDF-1.4");
|
||||
|
||||
let optional_params = json!({
|
||||
"formatting": {"table_output_format": "html"},
|
||||
"retrieval": {"chunk_mode": "section"},
|
||||
"settings": {"ocr_system": "standard"},
|
||||
})
|
||||
.as_object()
|
||||
.expect("params should be an object")
|
||||
.clone();
|
||||
let request = build_parse_v3_request("reducto://uploaded.pdf", optional_params);
|
||||
assert_eq!(
|
||||
request.data,
|
||||
json!({
|
||||
"input": "reducto://uploaded.pdf",
|
||||
"formatting": {"table_output_format": "html"},
|
||||
"retrieval": {"chunk_mode": "section"},
|
||||
"settings": {"ocr_system": "standard"},
|
||||
})
|
||||
);
|
||||
|
||||
let transformed = transform_reducto_response("parse-v3", parse_response.clone())
|
||||
.expect("response should transform");
|
||||
assert_eq!(
|
||||
transformed.usage_info,
|
||||
Some(json!({"pages_processed": 3, "credits": 3}))
|
||||
);
|
||||
assert_eq!(transformed.pages.len(), 3);
|
||||
assert_eq!(
|
||||
transformed.pages[0],
|
||||
json!({
|
||||
"index": 0,
|
||||
"markdown": "Page 1 block A\n\nPage 1 block B",
|
||||
"blocks": [
|
||||
{"content": "Page 1 block A", "bbox": {"page": 1}, "kind": "text"},
|
||||
{"content": "Page 1 block B", "bbox": {"page": 1}, "kind": "text"},
|
||||
],
|
||||
})
|
||||
);
|
||||
assert_eq!(transformed.pages[1]["markdown"], "Page 2 block A");
|
||||
assert_eq!(transformed.pages[2]["markdown"], "Page 3 block A");
|
||||
assert_eq!(transformed.provider_native_response, Some(parse_response));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_parse_v3_reducto_id_passthrough_skips_upload(parse_response: Value) {
|
||||
let document = json!({
|
||||
"type": "document_url",
|
||||
"document_url": "reducto://already-uploaded.pdf",
|
||||
});
|
||||
let source = extract_document_source(&document).expect("Reducto ID should be valid");
|
||||
assert!(build_upload_request(source.clone(), "Bearer test-key", None).is_none());
|
||||
assert_eq!(
|
||||
source,
|
||||
ReductoDocumentSource::FileId("reducto://already-uploaded.pdf".to_string())
|
||||
);
|
||||
|
||||
let request = REDUCTO_PARSE_V3_CONFIG
|
||||
.transform_ocr_request(
|
||||
"parse-v3",
|
||||
document,
|
||||
json!({"retrieval": {"chunk_mode": "section"}})
|
||||
.as_object()
|
||||
.expect("params should be object")
|
||||
.clone(),
|
||||
)
|
||||
.expect("direct ID should transform");
|
||||
assert_eq!(request.data["input"], "reducto://already-uploaded.pdf");
|
||||
assert_eq!(request.data["retrieval"]["chunk_mode"], "section");
|
||||
|
||||
let response = REDUCTO_PARSE_V3_CONFIG
|
||||
.transform_ocr_response("parse-v3", parse_response)
|
||||
.expect("response should transform");
|
||||
assert!(
|
||||
response.pages[0]["markdown"]
|
||||
.as_str()
|
||||
.expect("markdown should be string")
|
||||
.starts_with("Page 1 block A")
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_parse_legacy_wraps_enhance_under_options() {
|
||||
let request = build_parse_legacy_request(
|
||||
"reducto://legacy.pdf",
|
||||
json!({"enhance": {"agentic": [{"type": "table"}]}})
|
||||
.as_object()
|
||||
.expect("params should be object"),
|
||||
);
|
||||
assert_eq!(
|
||||
request.data,
|
||||
json!({
|
||||
"document_url": "reducto://legacy.pdf",
|
||||
"options": {"enhance": {"agentic": [{"type": "table"}]}},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_parse_v3_image_data_uri_upload_uses_image_mime() {
|
||||
let source = classify_document_source("data:image/png;base64,iVBORw0KGgo=")
|
||||
.expect("PNG data URI should be valid");
|
||||
let upload = build_upload_request(
|
||||
source,
|
||||
"Bearer programmatic-key",
|
||||
Some("https://custom.reducto.test/"),
|
||||
)
|
||||
.expect("data URI should require upload");
|
||||
assert_eq!(upload.url, "https://custom.reducto.test/upload");
|
||||
assert_eq!(upload.authorization, "Bearer programmatic-key");
|
||||
assert_eq!(upload.mime_type, "image/png");
|
||||
assert_eq!(upload.bytes, b"\x89PNG\r\n\x1a\n");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::http("http://example.com/document.pdf")]
|
||||
#[case::https("https://example.com/document.pdf")]
|
||||
fn test_parse_v3_rejects_plain_http_urls(#[case] source: &str) {
|
||||
let error = classify_document_source(source).expect_err("plain URL should be rejected");
|
||||
assert!(error.to_string().contains("upload the file first"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_parse_v3_uses_programmatic_api_key_over_env() {
|
||||
let key = resolve_api_key(Some("passed-key"), &|_| Some("env-reducto-key".to_string()))
|
||||
.expect("explicit key should resolve");
|
||||
assert_eq!(key, "passed-key");
|
||||
|
||||
let headers = REDUCTO_PARSE_V3_CONFIG
|
||||
.validate_environment(Vec::new(), Some("passed-key"), &|_| {
|
||||
Some("env-reducto-key".to_string())
|
||||
})
|
||||
.expect("headers should validate");
|
||||
assert_eq!(
|
||||
headers,
|
||||
vec![("Authorization".to_string(), "Bearer passed-key".to_string())]
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::error::{Error, json_type_name};
|
||||
use crate::ocr::transformation::OcrProviderConfig;
|
||||
use crate::ocr::types::{OcrRequestData, OcrResponseData};
|
||||
|
||||
pub const REDUCTO_API_BASE: &str = "https://platform.reducto.ai";
|
||||
pub const REDUCTO_API_KEY_ENV: &str = "REDUCTO_API_KEY";
|
||||
pub const REDUCTO_ID_PREFIX: &str = "reducto://";
|
||||
|
||||
const PARSE_V3_SUPPORTED_OCR_PARAMS: &[&str] = &["formatting", "retrieval", "settings"];
|
||||
const PARSE_LEGACY_SUPPORTED_OCR_PARAMS: &[&str] = &["enhance"];
|
||||
const MISSING_KEY_MESSAGE: &str = "Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()";
|
||||
const DATA_URI_UPLOAD_REQUIRED: &str =
|
||||
"Reducto data URI upload must complete before OCR request transformation";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ReductoDocumentSource {
|
||||
FileId(String),
|
||||
Upload { bytes: Vec<u8>, mime_type: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ReductoUploadRequest {
|
||||
pub url: String,
|
||||
pub authorization: String,
|
||||
pub file_name: &'static str,
|
||||
pub bytes: Vec<u8>,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
pub struct ReductoParseV3Config;
|
||||
pub struct ReductoParseLegacyConfig;
|
||||
|
||||
pub const REDUCTO_PARSE_V3_CONFIG: ReductoParseV3Config = ReductoParseV3Config;
|
||||
pub const REDUCTO_PARSE_LEGACY_CONFIG: ReductoParseLegacyConfig = ReductoParseLegacyConfig;
|
||||
|
||||
pub fn config_for_model(model: &str) -> Option<&'static dyn OcrProviderConfig> {
|
||||
match model {
|
||||
"parse-v3" => Some(&REDUCTO_PARSE_V3_CONFIG),
|
||||
"parse-legacy" => Some(&REDUCTO_PARSE_LEGACY_CONFIG),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_api_base(api_base: Option<&str>) -> String {
|
||||
api_base
|
||||
.map(str::trim)
|
||||
.filter(|base| !base.is_empty())
|
||||
.unwrap_or(REDUCTO_API_BASE)
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn parse_url(api_base: Option<&str>) -> String {
|
||||
format!("{}/parse", normalize_api_base(api_base))
|
||||
}
|
||||
|
||||
pub fn upload_url(api_base: Option<&str>) -> String {
|
||||
format!("{}/upload", normalize_api_base(api_base))
|
||||
}
|
||||
|
||||
pub fn resolve_api_key(
|
||||
api_key: Option<&str>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
api_key
|
||||
.map(str::trim)
|
||||
.filter(|key| !key.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
env_lookup(REDUCTO_API_KEY_ENV)
|
||||
.map(|key| key.trim().to_string())
|
||||
.filter(|key| !key.is_empty())
|
||||
})
|
||||
.ok_or_else(|| Error::Auth(MISSING_KEY_MESSAGE.to_string()))
|
||||
}
|
||||
|
||||
pub fn extract_document_source(document: &Value) -> Result<ReductoDocumentSource, Error> {
|
||||
let document = document.as_object().ok_or_else(|| Error::InvalidType {
|
||||
expected: "object",
|
||||
actual: json_type_name(document),
|
||||
})?;
|
||||
let source = document
|
||||
.get("document_url")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|source| !source.is_empty())
|
||||
.or_else(|| document.get("image_url").and_then(Value::as_str))
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidRequest(
|
||||
"Reducto expected OCR preprocessing to produce document_url or image_url"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
classify_document_source(source)
|
||||
}
|
||||
|
||||
pub fn classify_document_source(source: &str) -> Result<ReductoDocumentSource, Error> {
|
||||
if source.starts_with(REDUCTO_ID_PREFIX) {
|
||||
return Ok(ReductoDocumentSource::FileId(source.to_string()));
|
||||
}
|
||||
if source.starts_with("http://") || source.starts_with("https://") {
|
||||
return Err(Error::InvalidRequest(
|
||||
"Reducto requires type='file' (auto-uploaded) or a reducto:// id. Plain http(s) URLs are not supported; upload the file first."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if !source.starts_with("data:") {
|
||||
return Err(Error::InvalidRequest(
|
||||
"Reducto requires a reducto:// id or a base64 data URI after OCR preprocessing."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (header, encoded) = source
|
||||
.split_once(',')
|
||||
.ok_or_else(|| Error::InvalidRequest("Invalid Reducto data URI provided.".to_string()))?;
|
||||
if !header.split(';').any(|part| part == "base64") {
|
||||
return Err(Error::InvalidRequest(
|
||||
"Reducto only supports base64-encoded data URIs.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mime_type = header
|
||||
.strip_prefix("data:")
|
||||
.and_then(|header| header.split(';').next())
|
||||
.filter(|mime| !mime.is_empty())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = BASE64_STANDARD.decode(encoded).map_err(|_| {
|
||||
Error::InvalidRequest("Invalid Reducto base64 payload provided.".to_string())
|
||||
})?;
|
||||
|
||||
Ok(ReductoDocumentSource::Upload { bytes, mime_type })
|
||||
}
|
||||
|
||||
pub fn build_upload_request(
|
||||
source: ReductoDocumentSource,
|
||||
authorization: &str,
|
||||
api_base: Option<&str>,
|
||||
) -> Option<ReductoUploadRequest> {
|
||||
let ReductoDocumentSource::Upload { bytes, mime_type } = source else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(ReductoUploadRequest {
|
||||
url: upload_url(api_base),
|
||||
authorization: authorization.to_string(),
|
||||
file_name: "document",
|
||||
bytes,
|
||||
mime_type,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_upload_file_id(response_json: &Value) -> Result<&str, Error> {
|
||||
response_json
|
||||
.as_object()
|
||||
.and_then(|response| response.get("file_id"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|file_id| !file_id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidResponse(format!(
|
||||
"Reducto /upload returned 200 without a file_id; got payload={response_json}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_parse_v3_request(
|
||||
file_id: &str,
|
||||
optional_params: Map<String, Value>,
|
||||
) -> OcrRequestData {
|
||||
let data = std::iter::once(("input".to_string(), Value::String(file_id.to_string())))
|
||||
.chain(optional_params)
|
||||
.collect();
|
||||
OcrRequestData {
|
||||
data: Value::Object(data),
|
||||
files: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_parse_legacy_request(
|
||||
file_id: &str,
|
||||
optional_params: &Map<String, Value>,
|
||||
) -> OcrRequestData {
|
||||
let options = optional_params
|
||||
.get("enhance")
|
||||
.filter(|enhance| !enhance.is_null())
|
||||
.map(|enhance| json!({"options": {"enhance": enhance}}));
|
||||
let data = match options {
|
||||
Some(Value::Object(options)) => std::iter::once((
|
||||
"document_url".to_string(),
|
||||
Value::String(file_id.to_string()),
|
||||
))
|
||||
.chain(options)
|
||||
.collect(),
|
||||
_ => Map::from_iter([(
|
||||
"document_url".to_string(),
|
||||
Value::String(file_id.to_string()),
|
||||
)]),
|
||||
};
|
||||
OcrRequestData {
|
||||
data: Value::Object(data),
|
||||
files: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_file_id(document: &Value) -> Result<String, Error> {
|
||||
match extract_document_source(document)? {
|
||||
ReductoDocumentSource::FileId(file_id) => Ok(file_id),
|
||||
ReductoDocumentSource::Upload { .. } => Err(Error::Unsupported(DATA_URI_UPLOAD_REQUIRED)),
|
||||
}
|
||||
}
|
||||
|
||||
fn page_number(block: &Map<String, Value>) -> Option<i64> {
|
||||
let page = block.get("bbox")?.as_object()?.get("page")?;
|
||||
page.as_i64()
|
||||
.or_else(|| page.as_u64().and_then(|page| i64::try_from(page).ok()))
|
||||
.or_else(|| page.as_str().and_then(|page| page.parse().ok()))
|
||||
}
|
||||
|
||||
fn chunks(result: &Map<String, Value>) -> &[Value] {
|
||||
result
|
||||
.get("chunks")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build_pages(result: &Map<String, Value>) -> Vec<Value> {
|
||||
let blocks_by_page = chunks(result)
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.filter_map(|chunk| chunk.get("blocks").and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter_map(|block| block.as_object().map(|object| (block, object)))
|
||||
.filter_map(|(block, object)| page_number(object).map(|page| (page, block.clone())))
|
||||
.fold(
|
||||
BTreeMap::<i64, Vec<Value>>::new(),
|
||||
|mut pages, (page, block)| {
|
||||
pages.entry(page).or_default().push(block);
|
||||
pages
|
||||
},
|
||||
);
|
||||
|
||||
if blocks_by_page.is_empty() {
|
||||
let markdown = chunks(result)
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.filter_map(|chunk| chunk.get("content").and_then(Value::as_str))
|
||||
.filter(|content| !content.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
return if markdown.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![json!({"index": 0, "markdown": markdown})]
|
||||
};
|
||||
}
|
||||
|
||||
blocks_by_page
|
||||
.into_iter()
|
||||
.map(|(page, blocks)| {
|
||||
let markdown = blocks
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.filter_map(|block| block.get("content").and_then(Value::as_str))
|
||||
.filter(|content| !content.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
json!({
|
||||
"index": page.saturating_sub(1).max(0),
|
||||
"markdown": markdown,
|
||||
"blocks": blocks,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn transform_reducto_response(
|
||||
model: &str,
|
||||
response_json: Value,
|
||||
) -> Result<OcrResponseData, Error> {
|
||||
let response = response_json
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::InvalidType {
|
||||
expected: "object",
|
||||
actual: json_type_name(&response_json),
|
||||
})?;
|
||||
let empty_result = Map::new();
|
||||
let result = match response.get("result") {
|
||||
Some(Value::Object(result)) => result,
|
||||
Some(Value::Null) => &empty_result,
|
||||
Some(_) => {
|
||||
return Err(Error::InvalidResponse(
|
||||
"Reducto result must be an object".to_string(),
|
||||
));
|
||||
}
|
||||
None => response,
|
||||
};
|
||||
let usage = response
|
||||
.get("usage")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let usage_info = Some(json!({
|
||||
"pages_processed": usage.get("num_pages").cloned().unwrap_or(Value::Null),
|
||||
"credits": usage.get("credits").cloned().unwrap_or(Value::Null),
|
||||
}));
|
||||
|
||||
Ok(OcrResponseData {
|
||||
pages: build_pages(result),
|
||||
model: model.to_string(),
|
||||
document_annotation: None,
|
||||
usage_info,
|
||||
object: "ocr".to_string(),
|
||||
extra_fields: Map::new(),
|
||||
provider_native_response: Some(response_json),
|
||||
})
|
||||
}
|
||||
|
||||
impl OcrProviderConfig for ReductoParseV3Config {
|
||||
fn supported_ocr_params(&self) -> &'static [&'static str] {
|
||||
PARSE_V3_SUPPORTED_OCR_PARAMS
|
||||
}
|
||||
|
||||
fn transform_ocr_request(
|
||||
&self,
|
||||
_model: &str,
|
||||
document: Value,
|
||||
optional_params: Map<String, Value>,
|
||||
) -> Result<OcrRequestData, Error> {
|
||||
let file_id = source_file_id(&document)?;
|
||||
Ok(build_parse_v3_request(&file_id, optional_params))
|
||||
}
|
||||
|
||||
fn transform_ocr_response(
|
||||
&self,
|
||||
model: &str,
|
||||
response_json: Value,
|
||||
) -> Result<OcrResponseData, Error> {
|
||||
transform_reducto_response(model, response_json)
|
||||
}
|
||||
|
||||
fn complete_url(
|
||||
&self,
|
||||
api_base: Option<&str>,
|
||||
_model: &str,
|
||||
_optional_params: &Map<String, Value>,
|
||||
_env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
Ok(parse_url(api_base))
|
||||
}
|
||||
|
||||
fn resolve_api_key(
|
||||
&self,
|
||||
api_key: Option<&str>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
resolve_api_key(api_key, env_lookup)
|
||||
}
|
||||
}
|
||||
|
||||
impl OcrProviderConfig for ReductoParseLegacyConfig {
|
||||
fn supported_ocr_params(&self) -> &'static [&'static str] {
|
||||
PARSE_LEGACY_SUPPORTED_OCR_PARAMS
|
||||
}
|
||||
|
||||
fn transform_ocr_request(
|
||||
&self,
|
||||
_model: &str,
|
||||
document: Value,
|
||||
optional_params: Map<String, Value>,
|
||||
) -> Result<OcrRequestData, Error> {
|
||||
let file_id = source_file_id(&document)?;
|
||||
Ok(build_parse_legacy_request(&file_id, &optional_params))
|
||||
}
|
||||
|
||||
fn transform_ocr_response(
|
||||
&self,
|
||||
model: &str,
|
||||
response_json: Value,
|
||||
) -> Result<OcrResponseData, Error> {
|
||||
transform_reducto_response(model, response_json)
|
||||
}
|
||||
|
||||
fn complete_url(
|
||||
&self,
|
||||
api_base: Option<&str>,
|
||||
_model: &str,
|
||||
_optional_params: &Map<String, Value>,
|
||||
_env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
Ok(parse_url(api_base))
|
||||
}
|
||||
|
||||
fn resolve_api_key(
|
||||
&self,
|
||||
api_key: Option<&str>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
resolve_api_key(api_key, env_lookup)
|
||||
}
|
||||
}
|
||||
|
|
@ -212,6 +212,7 @@ impl OcrProviderConfig for VertexAiOcrConfig {
|
|||
MISTRAL_OCR_CONFIG.supported_ocr_params()
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
fn transform_ocr_request(
|
||||
&self,
|
||||
model: &str,
|
||||
|
|
@ -229,6 +230,7 @@ impl OcrProviderConfig for VertexAiOcrConfig {
|
|||
MISTRAL_OCR_CONFIG.transform_ocr_response(model, response_json)
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
fn complete_url(
|
||||
&self,
|
||||
api_base: Option<&str>,
|
||||
|
|
@ -253,10 +255,21 @@ impl OcrProviderConfig for VertexAiOcrConfig {
|
|||
}
|
||||
|
||||
impl OcrProviderConfig for VertexAiDeepSeekOcrConfig {
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
fn supported_ocr_params(&self) -> &'static [&'static str] {
|
||||
DEEPSEEK_SUPPORTED_OCR_PARAMS
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
fn map_ocr_params(&self, non_default_params: &Map<String, Value>) -> Map<String, Value> {
|
||||
non_default_params
|
||||
.iter()
|
||||
.filter(|(name, _)| DEEPSEEK_SUPPORTED_OCR_PARAMS.contains(&name.as_str()))
|
||||
.map(|(name, value)| (name.clone(), value.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
fn transform_ocr_request(
|
||||
&self,
|
||||
model: &str,
|
||||
|
|
@ -283,6 +296,7 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig {
|
|||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
fn transform_ocr_response(
|
||||
&self,
|
||||
model: &str,
|
||||
|
|
@ -335,9 +349,12 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig {
|
|||
document_annotation: object.get("document_annotation").cloned(),
|
||||
usage_info,
|
||||
object: "ocr".to_string(),
|
||||
extra_fields: Map::new(),
|
||||
provider_native_response: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
fn complete_url(
|
||||
&self,
|
||||
api_base: Option<&str>,
|
||||
|
|
@ -360,6 +377,7 @@ impl OcrProviderConfig for VertexAiDeepSeekOcrConfig {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[test]
|
||||
fn vertex_mistral_url_uses_project_location_and_model() {
|
||||
|
|
@ -411,6 +429,22 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::bare_model("deepseek-ocr-maas")]
|
||||
#[case::namespaced_model("deepseek-ai/deepseek-ocr-maas")]
|
||||
fn vertex_deepseek_request_uses_single_provider_namespace(#[case] model: &str) {
|
||||
let body = VERTEX_AI_DEEPSEEK_OCR_CONFIG
|
||||
.transform_ocr_request(
|
||||
model,
|
||||
json!({"type": "image_url", "image_url": "data:image/png;base64,AA=="}),
|
||||
Map::new(),
|
||||
)
|
||||
.expect("request transforms")
|
||||
.data;
|
||||
|
||||
assert_eq!(body["model"], "deepseek-ai/deepseek-ocr-maas");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_deepseek_response_wraps_markdown_content() {
|
||||
let response = VERTEX_AI_DEEPSEEK_OCR_CONFIG
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Enforcement: the litellm-rust workspace has exactly four crates.
|
||||
//! Enforcement: the litellm-rust workspace has exactly five crates.
|
||||
//!
|
||||
//! `core` (the Rust SDK), `ai-gateway` (the HTTP/WebSocket host),
|
||||
//! `core` (the Rust SDK), `config` (the config-loading boundary),
|
||||
//! `ai-gateway` (the HTTP/WebSocket host),
|
||||
//! `python-interop` (domain-neutral PyO3 primitives), and `python-bridge` (the
|
||||
//! PyO3 cdylib). Adding or removing a crate must be a
|
||||
//! deliberate act: this test fails until the allowlist here is updated, forcing
|
||||
|
|
@ -19,13 +20,20 @@ use std::path::{Path, PathBuf};
|
|||
/// workspace legitimately gains or loses a crate.
|
||||
const EXPECTED_MEMBERS: &[&str] = &[
|
||||
"crates/core",
|
||||
"crates/config",
|
||||
"crates/ai-gateway",
|
||||
"crates/python-interop",
|
||||
"crates/python-bridge",
|
||||
];
|
||||
|
||||
/// The crate subdirectory names that must exist under `crates/`.
|
||||
const EXPECTED_CRATE_DIRS: &[&str] = &["core", "ai-gateway", "python-interop", "python-bridge"];
|
||||
const EXPECTED_CRATE_DIRS: &[&str] = &[
|
||||
"core",
|
||||
"config",
|
||||
"ai-gateway",
|
||||
"python-interop",
|
||||
"python-bridge",
|
||||
];
|
||||
|
||||
const MISMATCH: &str = "litellm-rust crate set changed — update this allowlist AND litellm-rust/AGENTS.md, and justify the crate per the rule (crate = layer needing independent compilation / its own deps / a separate artifact).";
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,15 @@ default = ["abi3"]
|
|||
abi3 = ["pyo3/abi3-py310"]
|
||||
extension-module = ["pyo3/extension-module"]
|
||||
panic-test = []
|
||||
trace-parity = [
|
||||
"dep:tracing",
|
||||
"litellm-core/observability",
|
||||
"litellm-ai-gateway/trace-parity",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
futures-util.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
tracing = { workspace = true, optional = true }
|
||||
litellm-core = { workspace = true, features = ["bedrock-auth"] }
|
||||
litellm-ai-gateway = { workspace = true, default-features = false }
|
||||
litellm-python-interop.workspace = true
|
||||
|
|
@ -31,6 +35,7 @@ tokio.workspace = true
|
|||
[dev-dependencies]
|
||||
criterion = "0.8.2"
|
||||
tokio-tungstenite.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "serialization"
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
pub(crate) const FUNCTION_TRACE_TARGET: &str = "litellm::function_trace";
|
||||
|
|
@ -59,3 +59,41 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||
module.add("RustBridgeDeclined", py.get_type::<RustBridgeDeclined>())?;
|
||||
module.add("RustUpstreamError", py.get_type::<RustUpstreamError>())
|
||||
}
|
||||
|
||||
pub(crate) fn ocr_error_to_pyerr(err: Error) -> PyErr {
|
||||
match err {
|
||||
Error::MissingField("document_url" | "image_url") => {
|
||||
PyValueError::new_err("Document URL is required")
|
||||
}
|
||||
Error::Http { status, body } => RustUpstreamError::new_err((status, body)),
|
||||
other => core_error_to_pyerr(other),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ocr_error_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ocr_errors_preserve_python_validation_and_provider_details() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
for field in ["document_url", "image_url"] {
|
||||
let mapped = ocr_error_to_pyerr(Error::MissingField(field));
|
||||
assert!(mapped.is_instance_of::<PyValueError>(py));
|
||||
assert_eq!(mapped.value(py).to_string(), "Document URL is required");
|
||||
}
|
||||
let mapped = ocr_error_to_pyerr(Error::Http {
|
||||
status: 429,
|
||||
body: r#"{"message":"rate limited"}"#.to_string(),
|
||||
});
|
||||
assert!(mapped.is_instance_of::<RustUpstreamError>(py));
|
||||
let args: (u16, String) = mapped
|
||||
.value(py)
|
||||
.getattr("args")
|
||||
.and_then(|args| args.extract())
|
||||
.expect("OCR failures retain status and unprefixed provider message");
|
||||
assert_eq!(args, (429, r#"{"message":"rate limited"}"#.to_string()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,216 +1,22 @@
|
|||
use std::future::Future;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use litellm_core::observability::{FunctionTrace, FunctionTraceEvent};
|
||||
use serde::Serialize;
|
||||
use tracing::instrument::WithSubscriber;
|
||||
use tracing::span::{Attributes, Id};
|
||||
use tracing::{Dispatch, Level, Subscriber};
|
||||
use tracing_subscriber::filter::{LevelFilter, filter_fn};
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
use tracing_subscriber::{Layer, Registry};
|
||||
|
||||
use crate::constants::FUNCTION_TRACE_TARGET;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum TraceResponse<T> {
|
||||
Plain(T),
|
||||
Traced {
|
||||
response: T,
|
||||
trace: Vec<FunctionTraceEvent>,
|
||||
},
|
||||
pub(crate) struct TracedResponse<T> {
|
||||
response: T,
|
||||
trace: Vec<FunctionTraceEvent>,
|
||||
}
|
||||
|
||||
pub(crate) async fn trace_call<T, E>(
|
||||
pub(crate) async fn capture<T, E>(
|
||||
future: impl Future<Output = Result<T, E>>,
|
||||
enabled: bool,
|
||||
) -> Result<TraceResponse<T>, E> {
|
||||
if !enabled {
|
||||
return future.await.map(TraceResponse::Plain);
|
||||
}
|
||||
) -> Result<TracedResponse<T>, E> {
|
||||
let trace = FunctionTrace::default();
|
||||
let response = future.with_subscriber(trace.dispatcher()).await?;
|
||||
Ok(TraceResponse::Traced {
|
||||
Ok(TracedResponse {
|
||||
response,
|
||||
trace: trace.events(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct FunctionTraceEvent {
|
||||
pub function: &'static str,
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FunctionTrace {
|
||||
events: Arc<Mutex<Vec<FunctionTraceEvent>>>,
|
||||
}
|
||||
|
||||
impl FunctionTrace {
|
||||
pub fn dispatcher(&self) -> Dispatch {
|
||||
let filter = filter_fn(|metadata| {
|
||||
metadata.is_span()
|
||||
&& metadata.target() == FUNCTION_TRACE_TARGET
|
||||
&& *metadata.level() == Level::TRACE
|
||||
})
|
||||
.with_max_level_hint(LevelFilter::TRACE);
|
||||
Dispatch::new(
|
||||
Registry::default().with(
|
||||
FunctionTraceLayer {
|
||||
trace: self.clone(),
|
||||
}
|
||||
.with_filter(filter),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn events(&self) -> Vec<FunctionTraceEvent> {
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct FunctionTraceLayer {
|
||||
trace: FunctionTrace,
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for FunctionTraceLayer
|
||||
where
|
||||
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
|
||||
{
|
||||
fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, S>) {
|
||||
let depth = context
|
||||
.span(id)
|
||||
.map(|span| span.scope().skip(1).count())
|
||||
.unwrap_or_default();
|
||||
self.trace
|
||||
.events
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.push(FunctionTraceEvent {
|
||||
function: attributes.metadata().name(),
|
||||
depth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
async fn outer() {
|
||||
tokio::task::yield_now().await;
|
||||
inner().await;
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
async fn inner() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_futures_keep_separate_traces_across_yields() {
|
||||
use tracing::instrument::WithSubscriber;
|
||||
|
||||
let first = FunctionTrace::default();
|
||||
let second = FunctionTrace::default();
|
||||
let outside = FunctionTrace::default();
|
||||
|
||||
async {
|
||||
tokio::join!(
|
||||
outer().with_subscriber(first.dispatcher()),
|
||||
inner().with_subscriber(second.dispatcher()),
|
||||
);
|
||||
inner().await;
|
||||
}
|
||||
.with_subscriber(outside.dispatcher())
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
first.events(),
|
||||
vec![
|
||||
FunctionTraceEvent {
|
||||
function: "outer",
|
||||
depth: 0
|
||||
},
|
||||
FunctionTraceEvent {
|
||||
function: "inner",
|
||||
depth: 1
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
second.events(),
|
||||
vec![FunctionTraceEvent {
|
||||
function: "inner",
|
||||
depth: 0
|
||||
}],
|
||||
);
|
||||
assert_eq!(
|
||||
outside.events(),
|
||||
vec![FunctionTraceEvent {
|
||||
function: "inner",
|
||||
depth: 0
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_matching_spans_in_creation_order() {
|
||||
let trace = FunctionTrace::default();
|
||||
let dispatch = trace.dispatcher();
|
||||
|
||||
tracing::dispatcher::with_default(&dispatch, || {
|
||||
let _ignored = tracing::trace_span!(target: "other", "ignored");
|
||||
let _first = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name");
|
||||
let _wrong_level = tracing::debug_span!(target: FUNCTION_TRACE_TARGET, "wrong_level");
|
||||
let _second = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "same_name");
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
trace.events(),
|
||||
vec![
|
||||
FunctionTraceEvent {
|
||||
function: "same_name",
|
||||
depth: 0,
|
||||
},
|
||||
FunctionTraceEvent {
|
||||
function: "same_name",
|
||||
depth: 0,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_matching_span_nesting_depth() {
|
||||
let trace = FunctionTrace::default();
|
||||
let dispatch = trace.dispatcher();
|
||||
|
||||
tracing::dispatcher::with_default(&dispatch, || {
|
||||
let outer = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "outer");
|
||||
let _outer_guard = outer.enter();
|
||||
let _inner = tracing::trace_span!(target: FUNCTION_TRACE_TARGET, "inner");
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
trace.events(),
|
||||
vec![
|
||||
FunctionTraceEvent {
|
||||
function: "outer",
|
||||
depth: 0,
|
||||
},
|
||||
FunctionTraceEvent {
|
||||
function: "inner",
|
||||
depth: 1,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
mod constants;
|
||||
mod diagnostics;
|
||||
mod errors;
|
||||
mod execution;
|
||||
pub mod function_trace;
|
||||
#[cfg(feature = "trace-parity")]
|
||||
mod function_trace;
|
||||
mod marshal;
|
||||
mod routes;
|
||||
|
||||
|
|
@ -115,9 +115,43 @@ mod tests {
|
|||
.extract::<Vec<String>>()
|
||||
.expect("module names should be strings")
|
||||
.into_iter()
|
||||
.filter(|name| !name.starts_with("__"))
|
||||
.filter(|name| !name.starts_with('_'))
|
||||
.collect();
|
||||
assert_eq!(public_names, expected);
|
||||
|
||||
#[cfg(not(feature = "trace-parity"))]
|
||||
assert!(!module.hasattr("_trace").expect("module lookup should work"));
|
||||
|
||||
#[cfg(feature = "trace-parity")]
|
||||
{
|
||||
let trace = module
|
||||
.getattr("_trace")
|
||||
.expect("trace build should expose its diagnostic namespace");
|
||||
let trace_names: Vec<String> = trace
|
||||
.cast::<PyModule>()
|
||||
.expect("trace namespace should be a module")
|
||||
.dict()
|
||||
.keys()
|
||||
.extract::<Vec<String>>()
|
||||
.expect("trace names should be strings")
|
||||
.into_iter()
|
||||
.filter(|name| !name.starts_with("__"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
trace_names,
|
||||
[
|
||||
"ocr",
|
||||
"aocr",
|
||||
"transcription",
|
||||
"atranscription",
|
||||
"messages",
|
||||
"amessages",
|
||||
"chat_completions",
|
||||
"achat_completions",
|
||||
"gateway_messages",
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,16 +54,16 @@ bridge_route! {
|
|||
required = {
|
||||
model: String,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
audio: Value,
|
||||
audio: serde_json::Value,
|
||||
},
|
||||
optional = {
|
||||
api_key: Option<String>,
|
||||
api_base: Option<String>,
|
||||
custom_llm_provider: Option<String>,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
extra_headers: Option<Value>,
|
||||
extra_headers: Option<serde_json::Value>,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
optional_params: Option<Value>,
|
||||
optional_params: Option<serde_json::Value>,
|
||||
timeout_seconds: Option<f64>,
|
||||
},
|
||||
prepare = prepare_transcription,
|
||||
|
|
|
|||
|
|
@ -73,16 +73,16 @@ bridge_route! {
|
|||
required = {
|
||||
model: String,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
messages: Value,
|
||||
messages: serde_json::Value,
|
||||
},
|
||||
optional = {
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
optional_params: Option<Value>,
|
||||
optional_params: Option<serde_json::Value>,
|
||||
api_key: Option<String>,
|
||||
api_base: Option<String>,
|
||||
custom_llm_provider: Option<String>,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
extra_headers: Option<Value>,
|
||||
extra_headers: Option<serde_json::Value>,
|
||||
timeout_seconds: Option<f64>,
|
||||
},
|
||||
prepare = prepare_chat_completions,
|
||||
|
|
|
|||
|
|
@ -20,43 +20,33 @@ macro_rules! bridge_route {
|
|||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = ($($required_name),*, $($optional_name=None,)* trace=false))]
|
||||
#[pyo3(signature = ($($required_name),*, $($optional_name=None),*))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn $sync_name(
|
||||
py: pyo3::Python<'_>,
|
||||
$($(#[$required_attr])* $required_name: $required_type,)*
|
||||
$($(#[$optional_attr])* $optional_name: $optional_type,)*
|
||||
trace: bool,
|
||||
) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>> {
|
||||
let future = $prepare($inputs {
|
||||
$($required_name,)*
|
||||
$($optional_name),*
|
||||
})?;
|
||||
$crate::execution::run_sync(
|
||||
py,
|
||||
$crate::function_trace::trace_call(future, trace),
|
||||
$map_error,
|
||||
)
|
||||
$crate::execution::run_sync(py, future, $map_error)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = ($($required_name),*, $($optional_name=None,)* trace=false))]
|
||||
#[pyo3(signature = ($($required_name),*, $($optional_name=None),*))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn $async_name(
|
||||
py: pyo3::Python<'_>,
|
||||
$($(#[$required_attr])* $required_name: $required_type,)*
|
||||
$($(#[$optional_attr])* $optional_name: $optional_type,)*
|
||||
trace: bool,
|
||||
) -> pyo3::PyResult<pyo3::Bound<'_, pyo3::PyAny>> {
|
||||
let future = $prepare($inputs {
|
||||
$($required_name,)*
|
||||
$($optional_name),*
|
||||
})?;
|
||||
$crate::execution::run_async(
|
||||
py,
|
||||
$crate::function_trace::trace_call(future, trace),
|
||||
$map_error,
|
||||
)
|
||||
$crate::execution::run_async(py, future, $map_error)
|
||||
}
|
||||
|
||||
pub(super) fn register(
|
||||
|
|
@ -67,6 +57,71 @@ macro_rules! bridge_route {
|
|||
$crate::routes::definition::add_function(module, pyo3::wrap_pyfunction!($async_name, module)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "trace-parity")]
|
||||
mod trace {
|
||||
use pyo3::prelude::*;
|
||||
use super::{$inputs, $map_error, $prepare};
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = ($($required_name),*, $($optional_name=None),*))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn $sync_name(
|
||||
py: pyo3::Python<'_>,
|
||||
$($(#[$required_attr])* $required_name: $required_type,)*
|
||||
$($(#[$optional_attr])* $optional_name: $optional_type,)*
|
||||
) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>> {
|
||||
let future = $prepare($inputs {
|
||||
$($required_name,)*
|
||||
$($optional_name),*
|
||||
})?;
|
||||
$crate::execution::run_sync(
|
||||
py,
|
||||
$crate::function_trace::capture(future),
|
||||
$map_error,
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = ($($required_name),*, $($optional_name=None),*))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn $async_name(
|
||||
py: pyo3::Python<'_>,
|
||||
$($(#[$required_attr])* $required_name: $required_type,)*
|
||||
$($(#[$optional_attr])* $optional_name: $optional_type,)*
|
||||
) -> pyo3::PyResult<pyo3::Bound<'_, pyo3::PyAny>> {
|
||||
let future = $prepare($inputs {
|
||||
$($required_name,)*
|
||||
$($optional_name),*
|
||||
})?;
|
||||
$crate::execution::run_async(
|
||||
py,
|
||||
$crate::function_trace::capture(future),
|
||||
$map_error,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn register(
|
||||
module: &pyo3::Bound<'_, pyo3::types::PyModule>,
|
||||
) -> pyo3::PyResult<()> {
|
||||
$crate::routes::definition::add_function(
|
||||
module,
|
||||
pyo3::wrap_pyfunction!($sync_name, module)?,
|
||||
)?;
|
||||
$crate::routes::definition::add_function(
|
||||
module,
|
||||
pyo3::wrap_pyfunction!($async_name, module)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "trace-parity")]
|
||||
pub(super) fn register_trace(
|
||||
module: &pyo3::Bound<'_, pyo3::types::PyModule>,
|
||||
) -> pyo3::PyResult<()> {
|
||||
trace::register(module)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -130,20 +185,26 @@ mod tests {
|
|||
) -> PyResult<impl Future<Output = Result<String, Error>> + Send + 'static> {
|
||||
FUTURE_DROPPED.store(false, Ordering::SeqCst);
|
||||
let drop_guard = (inputs.value == "pending").then_some(DropGuard);
|
||||
Ok(async move {
|
||||
let _drop_guard = drop_guard;
|
||||
tokio::task::yield_now().await;
|
||||
match inputs.value.as_str() {
|
||||
"error" => Err(Error::InvalidRequest("synthetic error".to_string())),
|
||||
"map_panic" => Err(Error::InvalidRequest("panic in mapper".to_string())),
|
||||
"panic" => panic!("synthetic panic"),
|
||||
"pending" => {
|
||||
pending::<()>().await;
|
||||
unreachable!()
|
||||
}
|
||||
_ => Ok(inputs.value),
|
||||
Ok(execute_echo(inputs, drop_guard))
|
||||
}
|
||||
|
||||
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
|
||||
async fn execute_echo(
|
||||
inputs: EchoInputs,
|
||||
drop_guard: Option<DropGuard>,
|
||||
) -> Result<String, Error> {
|
||||
let _drop_guard = drop_guard;
|
||||
tokio::task::yield_now().await;
|
||||
match inputs.value.as_str() {
|
||||
"error" => Err(Error::InvalidRequest("synthetic error".to_string())),
|
||||
"map_panic" => Err(Error::InvalidRequest("panic in mapper".to_string())),
|
||||
"panic" => panic!("synthetic panic"),
|
||||
"pending" => {
|
||||
pending::<()>().await;
|
||||
unreachable!()
|
||||
}
|
||||
})
|
||||
_ => Ok(inputs.value),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_error(error: Error) -> PyErr {
|
||||
|
|
@ -164,22 +225,22 @@ mod tests {
|
|||
(
|
||||
"ocr",
|
||||
"aocr",
|
||||
"(model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None, trace=False)",
|
||||
"(model, document, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)",
|
||||
),
|
||||
(
|
||||
"transcription",
|
||||
"atranscription",
|
||||
"(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None, trace=False)",
|
||||
"(model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None)",
|
||||
),
|
||||
(
|
||||
"messages",
|
||||
"amessages",
|
||||
"(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None, trace=False)",
|
||||
"(model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)",
|
||||
),
|
||||
(
|
||||
"chat_completions",
|
||||
"achat_completions",
|
||||
"(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None, trace=False)",
|
||||
"(model, messages, optional_params=None, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None)",
|
||||
),
|
||||
];
|
||||
|
||||
|
|
@ -411,6 +472,32 @@ asyncio.run(exercise())
|
|||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "trace-parity")]
|
||||
#[test]
|
||||
fn diagnostic_route_returns_the_response_and_filtered_trace() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let module = PyModule::new(py, "synthetic").expect("module should be created");
|
||||
synthetic::register_trace(&module).expect("trace routes should register");
|
||||
let locals = PyDict::new(py);
|
||||
locals
|
||||
.set_item("routes", &module)
|
||||
.expect("module should enter Python locals");
|
||||
let code = CString::new(
|
||||
r#"
|
||||
result = routes.echo("traced")
|
||||
assert result == {
|
||||
"response": "traced",
|
||||
"trace": [{"function": "execute_echo", "depth": 0}],
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.expect("Python source should not contain null bytes");
|
||||
py.run(&code, Some(&locals), Some(&locals))
|
||||
.expect("diagnostic route should return its response and trace");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_registration_rejects_duplicate_python_names() {
|
||||
Python::initialize();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
use pyo3::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::errors::core_error_to_pyerr;
|
||||
|
||||
#[pyfunction]
|
||||
fn gateway_messages<'py>(
|
||||
py: Python<'py>,
|
||||
model_alias: String,
|
||||
provider_model: String,
|
||||
api_base: String,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)] body: Value,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let future = litellm_ai_gateway::trace_parity::messages_request(
|
||||
model_alias,
|
||||
provider_model,
|
||||
api_base,
|
||||
body,
|
||||
);
|
||||
crate::execution::run_async(
|
||||
py,
|
||||
crate::function_trace::capture(future),
|
||||
core_error_to_pyerr,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn register_trace(module: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
super::definition::add_function(module, wrap_pyfunction!(gateway_messages, module)?)
|
||||
}
|
||||
|
|
@ -50,14 +50,14 @@ bridge_route! {
|
|||
required = {
|
||||
model: String,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
body: Value,
|
||||
body: serde_json::Value,
|
||||
},
|
||||
optional = {
|
||||
api_key: Option<String>,
|
||||
api_base: Option<String>,
|
||||
custom_llm_provider: Option<String>,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
extra_headers: Option<Value>,
|
||||
extra_headers: Option<serde_json::Value>,
|
||||
timeout_seconds: Option<f64>,
|
||||
},
|
||||
prepare = prepare_messages,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ use pyo3::prelude::*;
|
|||
#[macro_use]
|
||||
mod definition;
|
||||
|
||||
#[cfg(feature = "trace-parity")]
|
||||
mod gateway_messages;
|
||||
|
||||
mod audio_transcription;
|
||||
mod chat_completions;
|
||||
mod messages;
|
||||
|
|
@ -12,5 +15,16 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||
ocr::register(module)?;
|
||||
audio_transcription::register(module)?;
|
||||
messages::register(module)?;
|
||||
chat_completions::register(module)
|
||||
chat_completions::register(module)?;
|
||||
#[cfg(feature = "trace-parity")]
|
||||
{
|
||||
let trace = PyModule::new(module.py(), "_trace")?;
|
||||
ocr::register_trace(&trace)?;
|
||||
audio_transcription::register_trace(&trace)?;
|
||||
messages::register_trace(&trace)?;
|
||||
chat_completions::register_trace(&trace)?;
|
||||
gateway_messages::register_trace(&trace)?;
|
||||
module.add_submodule(&trace)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use litellm_ai_gateway::io::ocr::{OcrRequest, ocr as run_ocr};
|
|||
use pyo3::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::errors::core_error_to_pyerr;
|
||||
use crate::errors::ocr_error_to_pyerr;
|
||||
use crate::marshal::{RouteOptions, RouteOptionsInputs, object_or_empty};
|
||||
|
||||
fn prepare_ocr(
|
||||
|
|
@ -56,18 +56,18 @@ bridge_route! {
|
|||
required = {
|
||||
model: String,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
document: Value,
|
||||
document: serde_json::Value,
|
||||
},
|
||||
optional = {
|
||||
api_key: Option<String>,
|
||||
api_base: Option<String>,
|
||||
custom_llm_provider: Option<String>,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
extra_headers: Option<Value>,
|
||||
extra_headers: Option<serde_json::Value>,
|
||||
#[pyo3(from_py_with = litellm_python_interop::from_py)]
|
||||
optional_params: Option<Value>,
|
||||
optional_params: Option<serde_json::Value>,
|
||||
timeout_seconds: Option<f64>,
|
||||
},
|
||||
prepare = prepare_ocr,
|
||||
errors = core_error_to_pyerr,
|
||||
errors = ocr_error_to_pyerr,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,423 +0,0 @@
|
|||
use std::future::Future;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::FutureExt;
|
||||
use litellm_core::error::Error;
|
||||
use litellm_python_interop::{Pythonized, panic_to_pyerr, release_gil};
|
||||
use pyo3::exceptions::PyRuntimeError;
|
||||
use pyo3::prelude::*;
|
||||
use serde::Serialize;
|
||||
use tokio::runtime::{Handle, Runtime};
|
||||
use tokio::time::{self, MissedTickBehavior};
|
||||
|
||||
pub(super) fn run_sync<T, F>(
|
||||
py: Python<'_>,
|
||||
future: F,
|
||||
map_error: fn(Error) -> PyErr,
|
||||
) -> PyResult<Py<PyAny>>
|
||||
where
|
||||
T: Serialize + Send + 'static,
|
||||
F: Future<Output = Result<T, Error>> + Send + 'static,
|
||||
{
|
||||
run_sync_on(
|
||||
py,
|
||||
pyo3_async_runtimes::tokio::get_runtime(),
|
||||
future,
|
||||
map_error,
|
||||
)
|
||||
}
|
||||
|
||||
fn run_sync_on<T, F>(
|
||||
py: Python<'_>,
|
||||
runtime: &Runtime,
|
||||
future: F,
|
||||
map_error: fn(Error) -> PyErr,
|
||||
) -> PyResult<Py<PyAny>>
|
||||
where
|
||||
T: Serialize + Send + 'static,
|
||||
F: Future<Output = Result<T, Error>> + Send + 'static,
|
||||
{
|
||||
if Handle::try_current().is_ok() {
|
||||
return Err(PyRuntimeError::new_err(
|
||||
"synchronous native routes cannot run from a Tokio context; use the async route",
|
||||
));
|
||||
}
|
||||
|
||||
let result = release_gil(py, move || runtime.block_on(wait_for_sync_result(future)))?;
|
||||
let result = map_core_result(result, map_error)?;
|
||||
Pythonized(result).into_pyobject(py).map(Bound::unbind)
|
||||
}
|
||||
|
||||
pub(super) fn run_async<T, F>(
|
||||
py: Python<'_>,
|
||||
future: F,
|
||||
map_error: fn(Error) -> PyErr,
|
||||
) -> PyResult<Bound<'_, PyAny>>
|
||||
where
|
||||
T: Serialize + Send + 'static,
|
||||
F: Future<Output = Result<T, Error>> + Send + 'static,
|
||||
{
|
||||
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||
let result = catch_route_panic(future).await?;
|
||||
let result = map_core_result(result, map_error)?;
|
||||
Ok(Pythonized(result))
|
||||
})
|
||||
}
|
||||
|
||||
fn map_core_result<T>(result: Result<T, Error>, map_error: fn(Error) -> PyErr) -> PyResult<T> {
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(error) => Err(
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| map_error(error)))
|
||||
.map_err(panic_to_pyerr)?,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn catch_route_panic<T, F>(future: F) -> PyResult<Result<T, Error>>
|
||||
where
|
||||
F: Future<Output = Result<T, Error>>,
|
||||
{
|
||||
AssertUnwindSafe(future)
|
||||
.catch_unwind()
|
||||
.await
|
||||
.map_err(panic_to_pyerr)
|
||||
}
|
||||
|
||||
async fn wait_for_sync_result<T, F>(future: F) -> PyResult<Result<T, Error>>
|
||||
where
|
||||
F: Future<Output = Result<T, Error>>,
|
||||
{
|
||||
let future = catch_route_panic(future);
|
||||
tokio::pin!(future);
|
||||
|
||||
let signal_interval = Duration::from_millis(50);
|
||||
let mut signal_checks =
|
||||
time::interval_at(time::Instant::now() + signal_interval, signal_interval);
|
||||
signal_checks.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut future => return result,
|
||||
_ = signal_checks.tick() => Python::attach(|py| py.check_signals())?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::CString;
|
||||
use std::future::poll_fn;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::task::Poll;
|
||||
use std::thread;
|
||||
use std::time::Instant;
|
||||
|
||||
use pyo3::panic::PanicException;
|
||||
use pyo3::types::{PyDict, PyModule};
|
||||
use serde::Serializer;
|
||||
use tokio::runtime::Builder;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn runtime_error(error: Error) -> PyErr {
|
||||
PyRuntimeError::new_err(error.to_string())
|
||||
}
|
||||
|
||||
fn panicking_error_mapper(_error: Error) -> PyErr {
|
||||
panic!("error mapper panicked")
|
||||
}
|
||||
|
||||
struct PanickingOutput;
|
||||
|
||||
static ASYNC_PROBE_COMPLETED: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
impl Serialize for PanickingOutput {
|
||||
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
panic!("serializer panicked")
|
||||
}
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn async_serialization_panic(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
|
||||
run_async(py, async { Ok(PanickingOutput) }, runtime_error)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn async_runtime_probe(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
|
||||
run_async(
|
||||
py,
|
||||
async {
|
||||
ASYNC_PROBE_COMPLETED.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(true)
|
||||
},
|
||||
runtime_error,
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn runtime_worker_count() -> usize {
|
||||
pyo3_async_runtimes::tokio::get_runtime()
|
||||
.metrics()
|
||||
.num_workers()
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn runtime_is_responsive(_py: Python<'_>, expected_completions: usize) -> bool {
|
||||
let completion_deadline = Instant::now() + Duration::from_secs(2);
|
||||
while ASYNC_PROBE_COMPLETED.load(Ordering::SeqCst) < expected_completions {
|
||||
if Instant::now() >= completion_deadline {
|
||||
return false;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
|
||||
let (heartbeat_tx, heartbeat_rx) = mpsc::sync_channel(1);
|
||||
pyo3_async_runtimes::tokio::get_runtime().spawn(async move {
|
||||
let _ = heartbeat_tx.send(());
|
||||
});
|
||||
heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok()
|
||||
}
|
||||
|
||||
fn extract_bool(py: Python<'_>, result: PyResult<Py<PyAny>>) -> bool {
|
||||
result
|
||||
.expect("route should complete")
|
||||
.bind(py)
|
||||
.extract()
|
||||
.expect("result should convert")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_runner_polls_future_on_the_caller_thread() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let caller_thread = std::thread::current().id();
|
||||
let result = run_sync(
|
||||
py,
|
||||
async move { Ok(std::thread::current().id() == caller_thread) },
|
||||
runtime_error,
|
||||
);
|
||||
|
||||
assert!(extract_bool(py, result));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_runner_releases_gil_while_waiting() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let result = run_sync(
|
||||
py,
|
||||
async {
|
||||
let gil_acquired = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::task::spawn_blocking(|| Python::attach(|_| true)),
|
||||
)
|
||||
.await;
|
||||
Ok(matches!(gil_acquired, Ok(Ok(true))))
|
||||
},
|
||||
runtime_error,
|
||||
);
|
||||
|
||||
assert!(extract_bool(py, result));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_runner_rejects_calls_from_a_tokio_context() {
|
||||
Python::initialize();
|
||||
let runtime = Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime should build");
|
||||
|
||||
let error = runtime.block_on(async {
|
||||
Python::attach(|py| {
|
||||
run_sync::<bool, _>(py, async { Ok(true) }, runtime_error)
|
||||
.expect_err("sync route should reject a nested Tokio runtime")
|
||||
})
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"RuntimeError: synchronous native routes cannot run from a Tokio context; use the async route"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_runner_can_drive_a_current_thread_runtime() {
|
||||
Python::initialize();
|
||||
let runtime = Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime should build");
|
||||
Python::attach(|py| {
|
||||
let result = run_sync_on(
|
||||
py,
|
||||
&runtime,
|
||||
async {
|
||||
tokio::task::yield_now().await;
|
||||
Ok(true)
|
||||
},
|
||||
runtime_error,
|
||||
);
|
||||
assert!(extract_bool(py, result));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_runner_maps_a_panicked_future() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let error = run_sync::<bool, _>(
|
||||
py,
|
||||
poll_fn(|_| -> Poll<Result<bool, Error>> { panic!("route future panicked") }),
|
||||
runtime_error,
|
||||
)
|
||||
.expect_err("panicked route should become a Python exception");
|
||||
|
||||
assert!(error.is_instance_of::<PanicException>(py));
|
||||
assert_eq!(error.to_string(), "PanicException: route future panicked");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_runner_maps_a_panicked_error_mapper() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let error = run_sync::<bool, _>(
|
||||
py,
|
||||
async { Err(Error::InvalidRequest("invalid".to_string())) },
|
||||
panicking_error_mapper,
|
||||
)
|
||||
.expect_err("panicked mapper should become a Python exception");
|
||||
|
||||
assert!(error.is_instance_of::<PanicException>(py));
|
||||
assert_eq!(error.to_string(), "PanicException: error mapper panicked");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_runner_surfaces_serializer_panics() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let error = run_sync(py, async { Ok(PanickingOutput) }, runtime_error)
|
||||
.expect_err("serializer panic should become a Python exception");
|
||||
|
||||
assert!(error.is_instance_of::<PanicException>(py));
|
||||
assert_eq!(error.to_string(), "PanicException: serializer panicked");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_runner_supports_concurrent_callers_on_the_shared_runtime() {
|
||||
Python::initialize();
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(2));
|
||||
let callers: Vec<_> = (0..2)
|
||||
.map(|_| {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
thread::spawn(move || {
|
||||
Python::attach(|py| {
|
||||
extract_bool(
|
||||
py,
|
||||
run_sync(
|
||||
py,
|
||||
async move {
|
||||
Ok(tokio::time::timeout(Duration::from_secs(2), barrier.wait())
|
||||
.await
|
||||
.is_ok())
|
||||
},
|
||||
runtime_error,
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let results: Vec<_> = callers
|
||||
.into_iter()
|
||||
.map(|caller| caller.join().expect("caller should not panic"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(results, vec![true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_runner_surfaces_serializer_panics() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let module = PyModule::new(py, "runtime").expect("module should be created");
|
||||
module
|
||||
.add_function(
|
||||
wrap_pyfunction!(async_serialization_panic, &module)
|
||||
.expect("function should wrap"),
|
||||
)
|
||||
.expect("function should register");
|
||||
let locals = PyDict::new(py);
|
||||
locals
|
||||
.set_item("runtime", &module)
|
||||
.expect("module should enter Python locals");
|
||||
let code = CString::new(
|
||||
r#"
|
||||
import asyncio
|
||||
|
||||
async def exercise():
|
||||
try:
|
||||
await runtime.async_serialization_panic()
|
||||
except BaseException as error:
|
||||
assert type(error).__name__ == "PanicException"
|
||||
assert str(error) == "serializer panicked"
|
||||
else:
|
||||
raise AssertionError("serializer panic was not raised")
|
||||
|
||||
asyncio.run(exercise())
|
||||
"#,
|
||||
)
|
||||
.expect("Python source should not contain null bytes");
|
||||
py.run(&code, Some(&locals), Some(&locals))
|
||||
.expect("serializer panic should reach the Python awaiter");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_result_delivery_does_not_stall_tokio_workers() {
|
||||
Python::initialize();
|
||||
ASYNC_PROBE_COMPLETED.store(0, Ordering::SeqCst);
|
||||
Python::attach(|py| {
|
||||
let module = PyModule::new(py, "runtime").expect("module should be created");
|
||||
for function in [
|
||||
wrap_pyfunction!(async_runtime_probe, &module).expect("function should wrap"),
|
||||
wrap_pyfunction!(runtime_worker_count, &module).expect("function should wrap"),
|
||||
wrap_pyfunction!(runtime_is_responsive, &module).expect("function should wrap"),
|
||||
] {
|
||||
module
|
||||
.add_function(function)
|
||||
.expect("function should register");
|
||||
}
|
||||
let locals = PyDict::new(py);
|
||||
locals
|
||||
.set_item("runtime", &module)
|
||||
.expect("module should enter Python locals");
|
||||
let code = CString::new(
|
||||
r#"
|
||||
import asyncio
|
||||
|
||||
async def exercise():
|
||||
worker_count = runtime.runtime_worker_count()
|
||||
awaitables = [runtime.async_runtime_probe() for _ in range(worker_count)]
|
||||
assert runtime.runtime_is_responsive(worker_count)
|
||||
assert await asyncio.gather(*awaitables) == [True] * worker_count
|
||||
|
||||
asyncio.run(exercise())
|
||||
"#,
|
||||
)
|
||||
.expect("Python source should not contain null bytes");
|
||||
py.run(&code, Some(&locals), Some(&locals))
|
||||
.expect("result delivery should leave Tokio workers responsive");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ warnings.filterwarnings("ignore", message=".*`ReadOnly` qualifier.*")
|
|||
### INIT VARIABLES #########################
|
||||
import threading
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Load .env before any other litellm imports so env vars (e.g. LITELLM_UI_SESSION_DURATION) are available
|
||||
import dotenv as _dotenv
|
||||
|
|
@ -45,8 +46,6 @@ from typing import (
|
|||
TYPE_CHECKING,
|
||||
Union,
|
||||
)
|
||||
from litellm.types.integrations.datadog import DatadogInitParams
|
||||
from litellm.types.integrations.newrelic import NewRelicInitParams
|
||||
from litellm._logging import (
|
||||
set_verbose,
|
||||
_turn_on_debug,
|
||||
|
|
@ -95,8 +94,7 @@ from litellm.constants import (
|
|||
DEFAULT_SOFT_BUDGET,
|
||||
DEFAULT_ALLOWED_FAILS,
|
||||
)
|
||||
import httpx
|
||||
|
||||
# httpx is lazy-loaded via __getattr__
|
||||
# register_async_client_cleanup is lazy-loaded and called on first access
|
||||
|
||||
litellm_mode = os.getenv("LITELLM_MODE", "DEV") # "PRODUCTION", "DEV"
|
||||
|
|
@ -364,8 +362,6 @@ guardrail_name_config_map: Dict[str, GuardrailItem] = {}
|
|||
include_cost_in_streaming_usage: bool = False
|
||||
reasoning_auto_summary: bool = False
|
||||
### PROMPTS ####
|
||||
from litellm.types.prompts.init_prompts import PromptSpec
|
||||
|
||||
prompt_name_config_map: Dict[str, PromptSpec] = {}
|
||||
|
||||
##################
|
||||
|
|
@ -1271,206 +1267,203 @@ openai_video_generation_models = ["sora-2"]
|
|||
# get_llm_provider is lazy-loaded via __getattr__
|
||||
# remove_index_from_tool_calls is lazy-loaded via __getattr__
|
||||
|
||||
# Import KeyManagementSettings here (before utils import) because _key_management_settings
|
||||
# is accessed during import time in secret_managers/main.py (via dd_tracing -> datadog -> _service_logger -> utils)
|
||||
from litellm.types.secret_managers.main import KeyManagementSettings
|
||||
# SDK symbols previously imported eagerly here are lazy-loaded via __getattr__
|
||||
# (_SDK_SYMBOLS_IMPORT_MAP in _lazy_imports_registry.py); mirrored under TYPE_CHECKING
|
||||
# so static type checkers still see them
|
||||
if TYPE_CHECKING:
|
||||
_key_management_settings: KeyManagementSettings
|
||||
|
||||
_key_management_settings: KeyManagementSettings = KeyManagementSettings()
|
||||
from .utils import client
|
||||
|
||||
# client must be imported immediately as it's used as a decorator at function definition time
|
||||
from .utils import client
|
||||
from .llms.custom_llm import CustomLLM
|
||||
from .llms.anthropic.common_utils import AnthropicModelInfo
|
||||
from .llms.ai21.chat.transformation import AI21ChatConfig, AI21ChatConfig as AI21Config
|
||||
from .llms.deprecated_providers.palm import (
|
||||
PalmConfig,
|
||||
) # here to prevent breaking changes
|
||||
from .llms.deprecated_providers.aleph_alpha import AlephAlphaConfig
|
||||
from .llms.gemini.common_utils import GeminiModelInfo
|
||||
|
||||
# Note: Most other utils imports are lazy-loaded via __getattr__ to avoid loading utils.py
|
||||
# (which imports tiktoken) at import time
|
||||
from .llms.vertex_ai.vertex_embeddings.transformation import (
|
||||
VertexAITextEmbeddingConfig,
|
||||
)
|
||||
|
||||
from .llms.custom_llm import CustomLLM
|
||||
from .llms.anthropic.common_utils import AnthropicModelInfo
|
||||
from .llms.ai21.chat.transformation import AI21ChatConfig, AI21ChatConfig as AI21Config
|
||||
from .llms.deprecated_providers.palm import (
|
||||
PalmConfig,
|
||||
) # here to prevent breaking changes
|
||||
from .llms.deprecated_providers.aleph_alpha import AlephAlphaConfig
|
||||
from .llms.gemini.common_utils import GeminiModelInfo
|
||||
vertexAITextEmbeddingConfig = VertexAITextEmbeddingConfig()
|
||||
|
||||
from .llms.bedrock.embed.amazon_titan_v2_transformation import (
|
||||
AmazonTitanV2Config,
|
||||
)
|
||||
from .llms.topaz.common_utils import TopazModelInfo
|
||||
|
||||
from .llms.vertex_ai.vertex_embeddings.transformation import (
|
||||
VertexAITextEmbeddingConfig,
|
||||
)
|
||||
# OpenAIOSeriesConfig is lazy loaded - openaiOSeriesConfig will be created on first access
|
||||
# OpenAIGPTConfig, OpenAIGPT5Config, etc. are lazy loaded - instances will be created on first access
|
||||
from .llms.xai.common_utils import XAIModelInfo
|
||||
|
||||
vertexAITextEmbeddingConfig = VertexAITextEmbeddingConfig()
|
||||
# PublicAI now uses JSON-based configuration (see litellm/llms/openai_like/providers.json)
|
||||
# All remaining configs are now lazy loaded - see _lazy_imports_registry.py
|
||||
|
||||
# Import LlmProviders here (before main import) because it's imported during import time
|
||||
# in multiple places including openai.py (via main import)
|
||||
|
||||
from .llms.bedrock.embed.amazon_titan_v2_transformation import (
|
||||
AmazonTitanV2Config,
|
||||
)
|
||||
from .llms.topaz.common_utils import TopazModelInfo
|
||||
## Lazy loading this is not straightforward, will leave it here for now.
|
||||
from .main import *
|
||||
from .compression import compress
|
||||
|
||||
# OpenAIOSeriesConfig is lazy loaded - openaiOSeriesConfig will be created on first access
|
||||
# OpenAIGPTConfig, OpenAIGPT5Config, etc. are lazy loaded - instances will be created on first access
|
||||
from .llms.xai.common_utils import XAIModelInfo
|
||||
# Skills API
|
||||
from .skills.main import (
|
||||
create_skill,
|
||||
acreate_skill,
|
||||
list_skills,
|
||||
alist_skills,
|
||||
get_skill,
|
||||
aget_skill,
|
||||
delete_skill,
|
||||
adelete_skill,
|
||||
)
|
||||
from .evals.main import (
|
||||
create_eval,
|
||||
acreate_eval,
|
||||
list_evals,
|
||||
alist_evals,
|
||||
get_eval,
|
||||
aget_eval,
|
||||
delete_eval,
|
||||
adelete_eval,
|
||||
cancel_eval,
|
||||
acancel_eval,
|
||||
create_run,
|
||||
acreate_run,
|
||||
list_runs,
|
||||
alist_runs,
|
||||
get_run,
|
||||
aget_run,
|
||||
delete_run,
|
||||
adelete_run,
|
||||
cancel_run,
|
||||
acancel_run,
|
||||
)
|
||||
from .integrations import *
|
||||
from .llms.custom_httpx.async_client_cleanup import close_litellm_async_clients
|
||||
from .exceptions import (
|
||||
AuthenticationError,
|
||||
InvalidRequestError,
|
||||
BadRequestError,
|
||||
ImageFetchError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
RateLimitError,
|
||||
RateLimitErrorCategory,
|
||||
RateLimitType,
|
||||
ServiceUnavailableError,
|
||||
BadGatewayError,
|
||||
OpenAIError,
|
||||
ContextWindowExceededError,
|
||||
ContentPolicyViolationError,
|
||||
BudgetExceededError,
|
||||
APIError,
|
||||
Timeout,
|
||||
APIConnectionError,
|
||||
UnsupportedParamsError,
|
||||
APIResponseValidationError,
|
||||
UnprocessableEntityError,
|
||||
InternalServerError,
|
||||
JSONSchemaValidationError,
|
||||
LITELLM_EXCEPTION_TYPES,
|
||||
MockException,
|
||||
)
|
||||
from .budget_manager import BudgetManager
|
||||
from .proxy.proxy_cli import run_server
|
||||
from .router import Router
|
||||
from .assistants.main import *
|
||||
from .batches.main import *
|
||||
from .images.main import *
|
||||
from .videos.main import *
|
||||
from .batch_completion.main import *
|
||||
from .rerank_api.main import *
|
||||
from .llms.anthropic.experimental_pass_through.messages.handler import *
|
||||
from .responses.main import *
|
||||
|
||||
# PublicAI now uses JSON-based configuration (see litellm/llms/openai_like/providers.json)
|
||||
# All remaining configs are now lazy loaded - see _lazy_imports_registry.py
|
||||
# Interactions API is available as litellm.interactions module
|
||||
# Usage: litellm.interactions.create(), litellm.interactions.get(), etc.
|
||||
from . import interactions
|
||||
from .interactions.agents.main import (
|
||||
acreate as acreate_agent,
|
||||
create as create_agent,
|
||||
alist as alist_agents,
|
||||
list as list_agents,
|
||||
aget as aget_agent,
|
||||
get as get_agent,
|
||||
adelete as adelete_agent,
|
||||
delete as delete_agent,
|
||||
alist_versions as alist_agent_versions,
|
||||
list_versions as list_agent_versions,
|
||||
)
|
||||
from .skills.main import (
|
||||
create_skill,
|
||||
acreate_skill,
|
||||
list_skills,
|
||||
alist_skills,
|
||||
get_skill,
|
||||
aget_skill,
|
||||
delete_skill,
|
||||
adelete_skill,
|
||||
)
|
||||
from .containers.main import *
|
||||
from .ocr.main import *
|
||||
from .rust_bridge import rust
|
||||
from .rag.main import *
|
||||
from .sandbox.main import *
|
||||
from .search.main import *
|
||||
from .realtime_api.main import (
|
||||
_arealtime,
|
||||
acreate_realtime_client_secret,
|
||||
acreate_realtime_transcription_session,
|
||||
arealtime_calls,
|
||||
)
|
||||
from .responses.main import _aresponses_websocket
|
||||
from .fine_tuning.main import *
|
||||
from .files.main import *
|
||||
from .vector_store_files.main import (
|
||||
acreate as avector_store_file_create,
|
||||
adelete as avector_store_file_delete,
|
||||
alist as avector_store_file_list,
|
||||
aretrieve as avector_store_file_retrieve,
|
||||
aretrieve_content as avector_store_file_content,
|
||||
aupdate as avector_store_file_update,
|
||||
create as vector_store_file_create,
|
||||
delete as vector_store_file_delete,
|
||||
list as vector_store_file_list,
|
||||
retrieve as vector_store_file_retrieve,
|
||||
retrieve_content as vector_store_file_content,
|
||||
update as vector_store_file_update,
|
||||
)
|
||||
from .scheduler import *
|
||||
|
||||
# Import LlmProviders here (before main import) because it's imported during import time
|
||||
# in multiple places including openai.py (via main import)
|
||||
from litellm.types.utils import LlmProviders
|
||||
### ADAPTERS ###
|
||||
import litellm.anthropic_interface as anthropic
|
||||
|
||||
## Lazy loading this is not straightforward, will leave it here for now.
|
||||
from .main import *
|
||||
from .compression import compress
|
||||
### Vector Store Registry ###
|
||||
|
||||
# Skills API
|
||||
from .skills.main import (
|
||||
create_skill,
|
||||
acreate_skill,
|
||||
list_skills,
|
||||
alist_skills,
|
||||
get_skill,
|
||||
aget_skill,
|
||||
delete_skill,
|
||||
adelete_skill,
|
||||
)
|
||||
from .evals.main import (
|
||||
create_eval,
|
||||
acreate_eval,
|
||||
list_evals,
|
||||
alist_evals,
|
||||
get_eval,
|
||||
aget_eval,
|
||||
delete_eval,
|
||||
adelete_eval,
|
||||
cancel_eval,
|
||||
acancel_eval,
|
||||
create_run,
|
||||
acreate_run,
|
||||
list_runs,
|
||||
alist_runs,
|
||||
get_run,
|
||||
aget_run,
|
||||
delete_run,
|
||||
adelete_run,
|
||||
cancel_run,
|
||||
acancel_run,
|
||||
)
|
||||
from .integrations import *
|
||||
from .llms.custom_httpx.async_client_cleanup import close_litellm_async_clients
|
||||
from .exceptions import (
|
||||
AuthenticationError,
|
||||
InvalidRequestError,
|
||||
BadRequestError,
|
||||
ImageFetchError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
RateLimitError,
|
||||
RateLimitErrorCategory,
|
||||
RateLimitType,
|
||||
ServiceUnavailableError,
|
||||
BadGatewayError,
|
||||
OpenAIError,
|
||||
ContextWindowExceededError,
|
||||
ContentPolicyViolationError,
|
||||
BudgetExceededError,
|
||||
APIError,
|
||||
Timeout,
|
||||
APIConnectionError,
|
||||
UnsupportedParamsError,
|
||||
APIResponseValidationError,
|
||||
UnprocessableEntityError,
|
||||
InternalServerError,
|
||||
JSONSchemaValidationError,
|
||||
LITELLM_EXCEPTION_TYPES,
|
||||
MockException,
|
||||
)
|
||||
from .budget_manager import BudgetManager
|
||||
from .proxy.proxy_cli import run_server
|
||||
from .router import Router
|
||||
from .assistants.main import *
|
||||
from .batches.main import *
|
||||
from .images.main import *
|
||||
from .videos.main import *
|
||||
from .batch_completion.main import *
|
||||
from .rerank_api.main import *
|
||||
from .llms.anthropic.experimental_pass_through.messages.handler import *
|
||||
from .responses.main import *
|
||||
### RAG ###
|
||||
from . import rag
|
||||
|
||||
# Interactions API is available as litellm.interactions module
|
||||
# Usage: litellm.interactions.create(), litellm.interactions.get(), etc.
|
||||
from . import interactions
|
||||
from .interactions.agents.main import (
|
||||
acreate as acreate_agent,
|
||||
create as create_agent,
|
||||
alist as alist_agents,
|
||||
list as list_agents,
|
||||
aget as aget_agent,
|
||||
get as get_agent,
|
||||
adelete as adelete_agent,
|
||||
delete as delete_agent,
|
||||
alist_versions as alist_agent_versions,
|
||||
list_versions as list_agent_versions,
|
||||
)
|
||||
from .skills.main import (
|
||||
create_skill,
|
||||
acreate_skill,
|
||||
list_skills,
|
||||
alist_skills,
|
||||
get_skill,
|
||||
aget_skill,
|
||||
delete_skill,
|
||||
adelete_skill,
|
||||
)
|
||||
from .containers.main import *
|
||||
from .ocr.main import *
|
||||
from .rust_bridge import use_litellm_rust
|
||||
from .rag.main import *
|
||||
from .sandbox.main import *
|
||||
from .search.main import *
|
||||
from .realtime_api.main import (
|
||||
_arealtime,
|
||||
acreate_realtime_client_secret,
|
||||
acreate_realtime_transcription_session,
|
||||
arealtime_calls,
|
||||
)
|
||||
from .responses.main import _aresponses_websocket
|
||||
from .fine_tuning.main import *
|
||||
from .files.main import *
|
||||
from .vector_store_files.main import (
|
||||
acreate as avector_store_file_create,
|
||||
adelete as avector_store_file_delete,
|
||||
alist as avector_store_file_list,
|
||||
aretrieve as avector_store_file_retrieve,
|
||||
aretrieve_content as avector_store_file_content,
|
||||
aupdate as avector_store_file_update,
|
||||
create as vector_store_file_create,
|
||||
delete as vector_store_file_delete,
|
||||
list as vector_store_file_list,
|
||||
retrieve as vector_store_file_retrieve,
|
||||
retrieve_content as vector_store_file_content,
|
||||
update as vector_store_file_update,
|
||||
)
|
||||
from .scheduler import *
|
||||
### CUSTOM LLMs ###
|
||||
|
||||
### CLI UTILITIES ###
|
||||
from litellm.litellm_core_utils.cli_token_utils import get_litellm_gateway_api_key
|
||||
|
||||
### PASSTHROUGH ###
|
||||
from .passthrough import allm_passthrough_route, llm_passthrough_route
|
||||
from .google_genai import agenerate_content
|
||||
|
||||
### ADAPTERS ###
|
||||
from .types.adapter import AdapterItem
|
||||
import litellm.anthropic_interface as anthropic
|
||||
|
||||
adapters: List[AdapterItem] = []
|
||||
|
||||
### Vector Store Registry ###
|
||||
from .vector_stores.vector_store_registry import (
|
||||
VectorStoreRegistry,
|
||||
VectorStoreIndexRegistry,
|
||||
)
|
||||
|
||||
vector_store_registry: Optional[VectorStoreRegistry] = None
|
||||
vector_store_index_registry: Optional[VectorStoreIndexRegistry] = None
|
||||
|
||||
### RAG ###
|
||||
from . import rag
|
||||
|
||||
### CUSTOM LLMs ###
|
||||
from .types.llms.custom_llm import CustomLLMItem
|
||||
|
||||
custom_provider_map: List[CustomLLMItem] = []
|
||||
_custom_providers: List[str] = [] # internal helper util, used to track names of custom providers
|
||||
disable_hf_tokenizer_download: Optional[bool] = (
|
||||
|
|
@ -1478,13 +1471,6 @@ disable_hf_tokenizer_download: Optional[bool] = (
|
|||
)
|
||||
global_disable_no_log_param: bool = False
|
||||
|
||||
### CLI UTILITIES ###
|
||||
from litellm.litellm_core_utils.cli_token_utils import get_litellm_gateway_api_key
|
||||
|
||||
### PASSTHROUGH ###
|
||||
from .passthrough import allm_passthrough_route, llm_passthrough_route
|
||||
from .google_genai import agenerate_content
|
||||
|
||||
### GLOBAL CONFIG ###
|
||||
global_bitbucket_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
|
@ -1508,10 +1494,21 @@ def set_global_gitlab_config(config: Dict[str, Any]) -> None:
|
|||
# Lazy loading system for heavy modules to reduce initial import time and memory usage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
from litellm.types.utils import ModelInfo as _ModelInfoType
|
||||
from litellm.types.utils import PriorityReservationSettings
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.caching.caching import Cache
|
||||
from litellm.types.adapter import AdapterItem
|
||||
from litellm.types.integrations.datadog import DatadogInitParams
|
||||
from litellm.types.integrations.newrelic import NewRelicInitParams
|
||||
from litellm.types.llms.custom_llm import CustomLLMItem
|
||||
from litellm.types.prompts.init_prompts import PromptSpec
|
||||
from litellm.vector_stores.vector_store_registry import (
|
||||
VectorStoreIndexRegistry,
|
||||
VectorStoreRegistry,
|
||||
)
|
||||
|
||||
# Type stubs for lazy-loaded configs to help mypy
|
||||
from .llms.bedrock.chat.converse_transformation import (
|
||||
|
|
@ -2187,16 +2184,6 @@ if TYPE_CHECKING:
|
|||
# Track if async client cleanup has been registered (for lazy loading)
|
||||
_async_client_cleanup_registered = False
|
||||
|
||||
# Eager loading for backwards compatibility with VCR and other HTTP recording tools
|
||||
# When LITELLM_DISABLE_LAZY_LOADING is set, lazy-loaded attributes are loaded at import time
|
||||
# For now, this only affects encoding (tiktoken) as it was the only reported issue
|
||||
# See: https://github.com/BerriAI/litellm/issues/18659
|
||||
# This ensures encoding is initialized before VCR starts recording HTTP requests
|
||||
if os.getenv("LITELLM_DISABLE_LAZY_LOADING", "").lower() in ("1", "true", "yes", "on"):
|
||||
# Load encoding at import time (pre-#18070 behavior)
|
||||
# This ensures encoding is initialized before VCR starts recording
|
||||
from .main import encoding
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Lazy import handler with cached registry for improved performance."""
|
||||
|
|
@ -2276,6 +2263,8 @@ def __getattr__(name: str) -> Any:
|
|||
"openAIGPT5Config": "OpenAIGPT5Config",
|
||||
"nvidiaNimConfig": "NvidiaNimConfig",
|
||||
"nvidiaNimEmbeddingConfig": "NvidiaNimEmbeddingConfig",
|
||||
"vertexAITextEmbeddingConfig": "VertexAITextEmbeddingConfig",
|
||||
"_key_management_settings": "KeyManagementSettings",
|
||||
}
|
||||
if name in _config_instances:
|
||||
from ._lazy_imports import get_litellm_globals
|
||||
|
|
@ -2393,7 +2382,30 @@ def __getattr__(name: str) -> Any:
|
|||
|
||||
return locals()[name]
|
||||
|
||||
from ._lazy_imports import lazy_import_litellm_submodule
|
||||
|
||||
submodule: Final = lazy_import_litellm_submodule(name)
|
||||
if submodule is not None:
|
||||
return submodule
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
from ._lazy_imports import LiteLLMModule
|
||||
from ._lazy_imports_registry import STAR_IMPORT_PUBLIC_NAMES
|
||||
|
||||
sys.modules[__name__].__class__ = LiteLLMModule
|
||||
|
||||
__all__ = list(STAR_IMPORT_PUBLIC_NAMES) # mutable-ok: star imports require __all__ to be a list of str
|
||||
|
||||
|
||||
# ALL_LITELLM_RESPONSE_TYPES is lazy-loaded via __getattr__ to avoid loading utils at import time
|
||||
|
||||
# Eager loading for backwards compatibility with VCR and other HTTP recording tools
|
||||
# When LITELLM_DISABLE_LAZY_LOADING is set, lazy-loaded attributes are loaded at import time
|
||||
# For now, this only affects encoding (tiktoken) as it was the only reported issue
|
||||
# See: https://github.com/BerriAI/litellm/issues/18659
|
||||
# This ensures encoding is initialized before VCR starts recording HTTP requests
|
||||
# This block stays at the bottom so __getattr__ can resolve attributes main.py needs during its import
|
||||
if os.getenv("LITELLM_DISABLE_LAZY_LOADING", "").lower() in ("1", "true", "yes", "on"):
|
||||
from .main import encoding
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ until they're actually needed.
|
|||
"""
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping
|
||||
from types import ModuleType
|
||||
from types import MappingProxyType, ModuleType
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
|
@ -34,6 +35,8 @@ from ._lazy_imports_registry import (
|
|||
_LITELLM_LOGGING_IMPORT_MAP,
|
||||
_LLM_CONFIGS_IMPORT_MAP,
|
||||
_LLM_PROVIDER_LOGIC_IMPORT_MAP,
|
||||
_SDK_MODULE_ALIASES,
|
||||
_SDK_SYMBOLS_IMPORT_MAP,
|
||||
_TOKEN_COUNTER_IMPORT_MAP,
|
||||
_TYPES_IMPORT_MAP,
|
||||
_TYPES_UTILS_IMPORT_MAP,
|
||||
|
|
@ -78,7 +81,10 @@ def _get_utils_globals() -> dict[str, object]:
|
|||
This is where we cache imported attributes so we don't import them twice.
|
||||
When you do `litellm.utils.some_function`, it gets stored in this dictionary.
|
||||
"""
|
||||
return sys.modules["litellm.utils"].__dict__
|
||||
cached: Final = sys.modules.get("litellm.utils")
|
||||
if cached is not None:
|
||||
return cached.__dict__
|
||||
return importlib.import_module("litellm.utils").__dict__
|
||||
|
||||
|
||||
def _get_module_level_client_timeout(litellm_globals: Mapping[str, Any]) -> "float | httpx.Timeout | None":
|
||||
|
|
@ -214,6 +220,10 @@ def _get_lazy_import_registry() -> dict[str, Callable[[str], object]]:
|
|||
_LAZY_IMPORT_REGISTRY[name] = _lazy_import_llm_provider_logic
|
||||
for name in UTILS_MODULE_NAMES:
|
||||
_LAZY_IMPORT_REGISTRY[name] = _lazy_import_utils_module
|
||||
for name in _SDK_SYMBOLS_IMPORT_MAP:
|
||||
_LAZY_IMPORT_REGISTRY.setdefault(name, _lazy_import_sdk_symbols)
|
||||
for name in _SDK_MODULE_ALIASES:
|
||||
_LAZY_IMPORT_REGISTRY.setdefault(name, _lazy_import_sdk_module_alias)
|
||||
|
||||
return _LAZY_IMPORT_REGISTRY
|
||||
|
||||
|
|
@ -229,7 +239,7 @@ def _module_attribute(module: ModuleType, attr_name: str) -> object:
|
|||
return attribute["value"]
|
||||
|
||||
|
||||
def _generic_lazy_import(name: str, import_map: dict[str, tuple[str, str]], category: str) -> object:
|
||||
def _generic_lazy_import(name: str, import_map: Mapping[str, tuple[str, str]], category: str) -> object:
|
||||
"""
|
||||
Generic function that handles lazy importing for most attributes.
|
||||
|
||||
|
|
@ -350,6 +360,86 @@ def _lazy_import_llm_provider_logic(name: str) -> object:
|
|||
return _generic_lazy_import(name, _LLM_PROVIDER_LOGIC_IMPORT_MAP, "LLM provider logic")
|
||||
|
||||
|
||||
def _lazy_import_sdk_symbols(name: str) -> object:
|
||||
"""Handler for SDK symbols previously imported eagerly at the bottom of litellm/__init__.py"""
|
||||
return _generic_lazy_import(name, _SDK_SYMBOLS_IMPORT_MAP, "SDK symbols")
|
||||
|
||||
|
||||
def _lazy_import_sdk_module_alias(name: str) -> object:
|
||||
"""Handler for litellm attributes that bind a module (e.g. litellm.anthropic)"""
|
||||
_globals: Final = get_litellm_globals()
|
||||
if name in _globals:
|
||||
return _globals[name]
|
||||
module: Final = importlib.import_module(_SDK_MODULE_ALIASES[name])
|
||||
_globals[name] = module # rebind-ok: caches the resolved module alias on the package
|
||||
return module
|
||||
|
||||
|
||||
_SHADOWABLE_SDK_FUNCTIONS: Final = MappingProxyType(
|
||||
{
|
||||
"batch_completion": ("litellm.batch_completion.main", "batch_completion"),
|
||||
"ocr": ("litellm.ocr.main", "ocr"),
|
||||
"responses": ("litellm.responses.main", "responses"),
|
||||
"search": ("litellm.search.main", "search"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _shadowable_function_property(name: str) -> property:
|
||||
"""Property keeping litellm.<name> bound to the SDK function even after the import
|
||||
machinery binds the identically named litellm.<name> subpackage onto the litellm module."""
|
||||
module_path, attr_name = _SHADOWABLE_SDK_FUNCTIONS[name]
|
||||
|
||||
def _get(module: ModuleType) -> object:
|
||||
stored: Final = module.__dict__.get(name)
|
||||
if stored is not None and not (isinstance(stored, ModuleType) and stored.__name__ == f"litellm.{name}"):
|
||||
return stored
|
||||
value: Final = _module_attribute(importlib.import_module(module_path), attr_name)
|
||||
module.__dict__[name] = value # rebind-ok: caches the resolved function on the litellm module
|
||||
return value
|
||||
|
||||
def _set(module: ModuleType, value: object) -> None:
|
||||
module.__dict__[name] = value # rebind-ok: property setter must store assignments on the module
|
||||
|
||||
return property(_get, _set)
|
||||
|
||||
|
||||
class LiteLLMModule(ModuleType):
|
||||
"""Module type installed on the litellm package so function names shadowed by
|
||||
same-named subpackages (litellm.responses, ...) keep resolving to the functions."""
|
||||
|
||||
batch_completion = _shadowable_function_property("batch_completion")
|
||||
ocr = _shadowable_function_property("ocr")
|
||||
responses = _shadowable_function_property("responses")
|
||||
search = _shadowable_function_property("search")
|
||||
|
||||
|
||||
def lazy_import_submodule(package: str, name: str) -> "ModuleType | None":
|
||||
"""Resolve <package>.<name> as a submodule (e.g. litellm.utils) when no other handler matches"""
|
||||
if name.startswith("__") or not name.isidentifier():
|
||||
return None
|
||||
qualified_name: Final = f"{package}.{name}"
|
||||
try:
|
||||
spec: Final = importlib.util.find_spec(qualified_name)
|
||||
except ModuleNotFoundError:
|
||||
return None
|
||||
if spec is None:
|
||||
return None
|
||||
try:
|
||||
module: Final = importlib.import_module(qualified_name)
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name == qualified_name:
|
||||
return None
|
||||
raise
|
||||
sys.modules[package].__dict__[name] = module # rebind-ok: caches the resolved submodule on the package
|
||||
return module
|
||||
|
||||
|
||||
def lazy_import_litellm_submodule(name: str) -> "ModuleType | None":
|
||||
"""Resolve litellm.<name> as a submodule (e.g. litellm.utils) when no other handler matches"""
|
||||
return lazy_import_submodule("litellm", name)
|
||||
|
||||
|
||||
def _lazy_import_utils_module(name: str) -> object:
|
||||
"""
|
||||
Handler for utils module lazy imports.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue