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'
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
cf8cef2e6a
773 changed files with 64032 additions and 20136 deletions
|
|
@ -2731,7 +2731,7 @@ jobs:
|
|||
- ~/.cache/uv
|
||||
- restore_cache:
|
||||
keys:
|
||||
- ui-e2e-node-deps-v2-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
|
||||
- ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
|
||||
- run:
|
||||
name: Install Node dependencies and Playwright
|
||||
# The cimg/python:3.12-browsers image already ships the Chromium system
|
||||
|
|
@ -2742,11 +2742,14 @@ jobs:
|
|||
command: |
|
||||
cd ui/litellm-dashboard
|
||||
npm ci
|
||||
cd ../../tests/e2e/ui
|
||||
npm ci
|
||||
npx playwright install chromium
|
||||
- save_cache:
|
||||
key: ui-e2e-node-deps-v2-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
|
||||
key: ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
|
||||
paths:
|
||||
- ui/litellm-dashboard/node_modules
|
||||
- tests/e2e/ui/node_modules
|
||||
- ~/.cache/ms-playwright
|
||||
- run:
|
||||
name: Build UI from source
|
||||
|
|
@ -2777,10 +2780,10 @@ jobs:
|
|||
name: Seed database
|
||||
command: |
|
||||
PGPASSWORD=e2epassword psql -h localhost -p 5432 -U e2euser -d litellm_e2e \
|
||||
-f ui/litellm-dashboard/e2e_tests/fixtures/seed.sql
|
||||
-f tests/e2e/ui/fixtures/seed.sql
|
||||
- run:
|
||||
name: Start mock LLM server
|
||||
command: uv run --no-sync python ui/litellm-dashboard/e2e_tests/fixtures/mock_llm_server/server.py
|
||||
command: uv run --no-sync python tests/e2e/ui/fixtures/mock_llm_server/server.py
|
||||
background: true
|
||||
- run:
|
||||
name: Start LiteLLM proxy
|
||||
|
|
@ -2798,7 +2801,7 @@ jobs:
|
|||
command: |
|
||||
LITELLM_LICENSE="$LITELLM_LICENSE" \
|
||||
uv run --no-sync python -m litellm.proxy.proxy_cli \
|
||||
--config ui/litellm-dashboard/e2e_tests/fixtures/config.yml \
|
||||
--config tests/e2e/ui/fixtures/config.yml \
|
||||
--port 4000
|
||||
background: true
|
||||
- run:
|
||||
|
|
@ -2819,15 +2822,15 @@ jobs:
|
|||
# Forward LITELLM_LICENSE so license.spec.ts can detect that the
|
||||
# proxy was launched with a license and assert premium_user=true.
|
||||
command: |
|
||||
cd ui/litellm-dashboard
|
||||
cd tests/e2e/ui
|
||||
LITELLM_LICENSE="$LITELLM_LICENSE" \
|
||||
npx playwright test --config e2e_tests/playwright.config.ts
|
||||
npx playwright test --config playwright.config.ts
|
||||
no_output_timeout: 10m
|
||||
- store_artifacts:
|
||||
path: ui/litellm-dashboard/test-results
|
||||
path: tests/e2e/ui/test-results
|
||||
destination: e2e-test-results
|
||||
- store_artifacts:
|
||||
path: ui/litellm-dashboard/playwright-report
|
||||
path: tests/e2e/ui/playwright-report
|
||||
destination: e2e-playwright-report
|
||||
|
||||
e2e_ui_testing_server_root_path:
|
||||
|
|
@ -2870,17 +2873,20 @@ jobs:
|
|||
- ~/.cache/uv
|
||||
- restore_cache:
|
||||
keys:
|
||||
- ui-e2e-node-deps-v2-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
|
||||
- ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
|
||||
- run:
|
||||
name: Install Node dependencies and Playwright
|
||||
command: |
|
||||
cd ui/litellm-dashboard
|
||||
npm ci
|
||||
cd ../../tests/e2e/ui
|
||||
npm ci
|
||||
npx playwright install chromium
|
||||
- save_cache:
|
||||
key: ui-e2e-node-deps-v2-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
|
||||
key: ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
|
||||
paths:
|
||||
- ui/litellm-dashboard/node_modules
|
||||
- tests/e2e/ui/node_modules
|
||||
- ~/.cache/ms-playwright
|
||||
- run:
|
||||
name: Build UI from source
|
||||
|
|
@ -2902,10 +2908,10 @@ jobs:
|
|||
name: Seed database
|
||||
command: |
|
||||
PGPASSWORD=e2epassword psql -h localhost -p 5432 -U e2euser -d litellm_e2e \
|
||||
-f ui/litellm-dashboard/e2e_tests/fixtures/seed.sql
|
||||
-f tests/e2e/ui/fixtures/seed.sql
|
||||
- run:
|
||||
name: Start mock LLM server
|
||||
command: uv run --no-sync python ui/litellm-dashboard/e2e_tests/fixtures/mock_llm_server/server.py
|
||||
command: uv run --no-sync python tests/e2e/ui/fixtures/mock_llm_server/server.py
|
||||
background: true
|
||||
- run:
|
||||
name: Start LiteLLM proxy under a server root path
|
||||
|
|
@ -2918,7 +2924,7 @@ jobs:
|
|||
command: |
|
||||
LITELLM_LICENSE="$LITELLM_LICENSE" \
|
||||
uv run --no-sync python -m litellm.proxy.proxy_cli \
|
||||
--config ui/litellm-dashboard/e2e_tests/fixtures/config.yml \
|
||||
--config tests/e2e/ui/fixtures/config.yml \
|
||||
--port 4000
|
||||
background: true
|
||||
- run:
|
||||
|
|
@ -2937,15 +2943,15 @@ jobs:
|
|||
- run:
|
||||
name: Run migration smoke under SERVER_ROOT_PATH
|
||||
command: |
|
||||
cd ui/litellm-dashboard
|
||||
cd tests/e2e/ui
|
||||
LITELLM_LICENSE="$LITELLM_LICENSE" \
|
||||
npx playwright test --config e2e_tests/migration.serverRootPath.config.ts
|
||||
npx playwright test --config migration.serverRootPath.config.ts
|
||||
no_output_timeout: 10m
|
||||
- store_artifacts:
|
||||
path: ui/litellm-dashboard/test-results
|
||||
path: tests/e2e/ui/test-results
|
||||
destination: e2e-server-root-path-test-results
|
||||
- store_artifacts:
|
||||
path: ui/litellm-dashboard/playwright-report
|
||||
path: tests/e2e/ui/playwright-report
|
||||
destination: e2e-server-root-path-playwright-report
|
||||
|
||||
build_docker_database_image:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ has_backend=false
|
|||
while IFS= read -r file || [ -n "$file" ]; do
|
||||
[ -n "$file" ] || continue
|
||||
case "$file" in
|
||||
ui/*) has_client=true ;;
|
||||
ui/* | tests/e2e/ui/*) has_client=true ;;
|
||||
docs/* | *.md | *.mdx) : ;;
|
||||
*) has_backend=true ;;
|
||||
esac
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -30,7 +30,7 @@ body:
|
|||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Please provide detailed steps to reproduce this bug(A curl/python code to reproduce the bug)
|
||||
description: Please provide a numbered list of the exact steps to reproduce this bug (include a curl/python snippet to reproduce it). Number each step (1., 2., 3., ...) in the order you performed them.
|
||||
placeholder: |
|
||||
1. config.yaml file/ .env file/ etc.
|
||||
2. Run the following code...
|
||||
|
|
|
|||
15
.github/pull_request_template.md
vendored
15
.github/pull_request_template.md
vendored
|
|
@ -1,3 +1,18 @@
|
|||
## TLDR
|
||||
|
||||
<!-- Fill in the bullets below and keep each one short and concrete: one line per bullet, roughly 10 words max
|
||||
This section must be extremely human parsable, comprehensible, and readable: its target audience is humans, not AI agents -->
|
||||
|
||||
Problem this solves:
|
||||
|
||||
- <blah>
|
||||
- ...
|
||||
|
||||
How it solves it:
|
||||
|
||||
- <blah>
|
||||
- ...
|
||||
|
||||
## Relevant issues
|
||||
|
||||
<!-- e.g., "Fixes #000" -->
|
||||
|
|
|
|||
61
.github/workflows/create_daily_oss_branch.yml
vendored
61
.github/workflows/create_daily_oss_branch.yml
vendored
|
|
@ -1,61 +0,0 @@
|
|||
name: Create Daily OSS Branch
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 16 * * 1-5" # 9am PT during daylight saving time, weekdays.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
date:
|
||||
description: "Branch date in YYYY_MM_DD format. Defaults to today's UTC date."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
create-oss-branch:
|
||||
if: github.repository == 'BerriAI/litellm'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create dated OSS branch
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REQUESTED_DATE: ${{ inputs.date }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${REQUESTED_DATE}" ]; then
|
||||
if ! echo "${REQUESTED_DATE}" | grep -Eq '^[0-9]{4}_[0-9]{2}_[0-9]{2}$'; then
|
||||
echo "::error::date must use YYYY_MM_DD format, got '${REQUESTED_DATE}'"
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_DATE="${REQUESTED_DATE}"
|
||||
else
|
||||
BRANCH_DATE="$(date -u +'%Y_%m_%d')"
|
||||
fi
|
||||
|
||||
BRANCH_NAME="litellm_oss_daily_${BRANCH_DATE}"
|
||||
echo "Creating branch: ${BRANCH_NAME}"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git fetch origin main "${BRANCH_NAME}" || true
|
||||
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/${BRANCH_NAME}"; then
|
||||
echo "Branch ${BRANCH_NAME} already exists. Skipping creation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git checkout -b "${BRANCH_NAME}" origin/main
|
||||
git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${BRANCH_NAME}"
|
||||
echo "Successfully created and pushed branch: ${BRANCH_NAME}"
|
||||
4
.github/workflows/guard-main-branch.yml
vendored
4
.github/workflows/guard-main-branch.yml
vendored
|
|
@ -31,12 +31,12 @@ jobs:
|
|||
echo "PR head repo: $HEAD_REPO"
|
||||
echo "PR head branch: $HEAD_REF"
|
||||
if [ "$HEAD_REPO" != "$BASE_REPO" ]; then
|
||||
echo "::error::PRs to main must originate from the canonical repository ($BASE_REPO), not a fork ($HEAD_REPO). External contributors should open PRs against the current daily OSS branch (named litellm_oss_daily_YYYY_MM_DD; a fresh one is cut each weekday, so target the most recent) instead."
|
||||
echo "::error::PRs to main must originate from the canonical repository ($BASE_REPO), not a fork ($HEAD_REPO). External contributors should open PRs against 'litellm_internal_staging' instead."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$HEAD_REF" = "litellm_internal_staging" ] || [[ "$HEAD_REF" == litellm_hotfix_?* ]]; then
|
||||
echo "Allowed source branch."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::PRs to main must originate from 'litellm_internal_staging' or a 'litellm_hotfix_*' branch. Got: '$HEAD_REF'. If this is a contribution, retarget the PR against the current daily OSS branch (named litellm_oss_daily_YYYY_MM_DD; a fresh one is cut each weekday, so target the most recent) instead."
|
||||
echo "::error::PRs to main must originate from 'litellm_internal_staging' or a 'litellm_hotfix_*' branch. Got: '$HEAD_REF'. If this is a contribution, retarget the PR against 'litellm_internal_staging' instead."
|
||||
exit 1
|
||||
|
|
|
|||
20
.github/workflows/image-scan.yml
vendored
20
.github/workflows/image-scan.yml
vendored
|
|
@ -9,6 +9,7 @@ on:
|
|||
- "litellm_**"
|
||||
paths:
|
||||
- docker/Dockerfile.non_root
|
||||
- tests/proxy_migration_tests/test_offline_image_migration.py
|
||||
- uv.lock
|
||||
- ui/litellm-dashboard/package-lock.json
|
||||
- .github/workflows/image-scan.yml
|
||||
|
|
@ -51,6 +52,23 @@ jobs:
|
|||
- name: Build runtime image
|
||||
run: docker build -f docker/Dockerfile.non_root -t litellm-image-scan:${{ github.sha }} .
|
||||
|
||||
# The prisma bake must migrate a fresh DB with no egress as an arbitrary
|
||||
# non-root uid (OpenShift restricted-v2 / air-gapped / readOnlyRootFilesystem).
|
||||
# `docker run` as the default uid with network hides a broken bake because
|
||||
# the migration entrypoint exits 0 even when it applied nothing; asserting
|
||||
# the schema was created is what catches it.
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Verify offline migration as a non-root uid
|
||||
env:
|
||||
LITELLM_IMAGE: litellm-image-scan:${{ github.sha }}
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
|
||||
|
||||
# Scans the whole shipped artifact: OS/apk plus every language package
|
||||
# baked into the image, including ones no lockfile declares (e.g. prisma's
|
||||
# vendored node engine) that osv-scan cannot see. osv-scan stays the fast
|
||||
|
|
@ -58,6 +76,8 @@ jobs:
|
|||
# free OSS, run as a pinned, checksum-verified binary; no GitHub Action
|
||||
# dependency and no vendor SaaS callout.
|
||||
- name: Scan image for fixable HIGH/CRITICAL CVEs
|
||||
env:
|
||||
GRYPE_MATCH_PYTHON_USING_CPES: "true"
|
||||
run: |
|
||||
"$RUNNER_TEMP/grype" litellm-image-scan:${{ github.sha }} \
|
||||
--only-fixed \
|
||||
|
|
|
|||
50
.github/workflows/oss_daily_guardrails.yml
vendored
50
.github/workflows/oss_daily_guardrails.yml
vendored
|
|
@ -1,50 +0,0 @@
|
|||
name: OSS Daily Guardrails
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "litellm_oss_daily_20*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "litellm_oss_daily_20*"
|
||||
- litellm_internal_staging
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
oss-safe-checks:
|
||||
name: Run OSS daily safe checks
|
||||
if: startsWith(github.ref_name, 'litellm_oss_daily_20') || startsWith(github.head_ref, 'litellm_oss_daily_20') || startsWith(github.base_ref, 'litellm_oss_daily_20')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Run secret scan test
|
||||
run: |
|
||||
uv run --frozen --with 'pytest==9.0.2' pytest tests/litellm/test_no_hardcoded_secrets.py -v
|
||||
|
||||
- name: Run Ruff
|
||||
run: |
|
||||
uv sync --frozen
|
||||
cd litellm
|
||||
uv run --no-sync ruff check .
|
||||
3
.github/workflows/test-code-quality.yml
vendored
3
.github/workflows/test-code-quality.yml
vendored
|
|
@ -115,6 +115,9 @@ jobs:
|
|||
- name: check_fastuuid_usage
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/check_fastuuid_usage.py
|
||||
|
||||
- name: check_e2e_no_raw_requests
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/check_e2e_no_raw_requests.py
|
||||
|
||||
- name: memory_test
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/memory_test.py
|
||||
|
||||
|
|
|
|||
57
.github/workflows/test-litellm-ui-unit.yml
vendored
Normal file
57
.github/workflows/test-litellm-ui-unit.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
name: UI Unit Tests
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
push:
|
||||
branches:
|
||||
- litellm_internal_staging
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ui-unit-tests:
|
||||
runs-on: ubuntu-latest-16-cores
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui/litellm-dashboard
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: ui/litellm-dashboard/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run UI unit tests (Vitest)
|
||||
env:
|
||||
CI: "true"
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ -n "$BASE_SHA" ]; then
|
||||
echo "Pull request: running only tests related to changes since $BASE_SHA"
|
||||
npm run test -- --run --changed "$BASE_SHA" --passWithNoTests \
|
||||
--pool forks --poolOptions.forks.maxForks=14
|
||||
else
|
||||
echo "Push to $GITHUB_REF_NAME: running the full suite"
|
||||
npm run test -- --run --pool forks --poolOptions.forks.maxForks=14
|
||||
fi
|
||||
|
|
@ -46,6 +46,7 @@ jobs:
|
|||
tests/test_litellm/proxy/rag_endpoints
|
||||
tests/test_litellm/proxy/realtime_endpoints
|
||||
tests/test_litellm/proxy/ui_crud_endpoints
|
||||
tests/test_litellm/proxy/config_resolvers
|
||||
tests/test_litellm/proxy/utils
|
||||
workers: 2
|
||||
reruns: 2
|
||||
|
|
|
|||
10
.github/workflows/test_server_root_path.yml
vendored
10
.github/workflows/test_server_root_path.yml
vendored
|
|
@ -106,8 +106,8 @@ jobs:
|
|||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install UI deps and Chromium
|
||||
working-directory: ui/litellm-dashboard
|
||||
- name: Install e2e deps and Chromium
|
||||
working-directory: tests/e2e/ui
|
||||
run: |
|
||||
retry() {
|
||||
local attempt=1
|
||||
|
|
@ -131,17 +131,17 @@ jobs:
|
|||
retry npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run SERVER_ROOT_PATH redirect e2e
|
||||
working-directory: ui/litellm-dashboard
|
||||
working-directory: tests/e2e/ui
|
||||
env:
|
||||
SERVER_ROOT_PATH: ${{ matrix.root_path }}
|
||||
run: npx playwright test --config=e2e_tests/serverRootPath.config.ts
|
||||
run: npx playwright test --config=serverRootPath.config.ts
|
||||
|
||||
- name: Upload Playwright artifacts on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: playwright-trace-${{ strategy.job-index }}
|
||||
path: ui/litellm-dashboard/test-results/
|
||||
path: tests/e2e/ui/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup
|
||||
|
|
|
|||
81
.github/workflows/weekly_load_anomaly.yml
vendored
Normal file
81
.github/workflows/weekly_load_anomaly.yml
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
name: "Weekly Load Anomaly Check"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * 6"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
weekly-load-anomaly:
|
||||
if: github.event_name != 'schedule' || github.repository == 'BerriAI/litellm'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16.6
|
||||
env:
|
||||
POSTGRES_USER: llmproxy
|
||||
POSTGRES_PASSWORD: dbpassword9090
|
||||
POSTGRES_DB: litellm
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U llmproxy"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
DATABASE_URL: postgresql://llmproxy:dbpassword9090@localhost:5432/litellm
|
||||
LITELLM_MASTER_KEY: sk-weekly-anomaly-check
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra proxy
|
||||
|
||||
- name: Generate Prisma client
|
||||
env:
|
||||
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
|
||||
run: |
|
||||
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
|
||||
|
||||
- name: Start the proxy
|
||||
run: |
|
||||
nohup uv run --no-sync litellm --config tests/e2e/load/weekly_anomaly_config.yml --port 4000 > proxy.log 2>&1 &
|
||||
for _ in $(seq 1 90); do
|
||||
if curl -fs http://localhost:4000/health/liveliness > /dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "proxy never became live"
|
||||
tail -n 100 proxy.log
|
||||
exit 1
|
||||
|
||||
- name: Run the weekly session anomaly test
|
||||
env:
|
||||
E2E_WEEKLY_ANOMALY: "1"
|
||||
run: |
|
||||
uv run --no-sync pytest tests/e2e/load/test_weekly_session_anomaly_e2e.py -v --tb=short -rA
|
||||
|
||||
- name: Show proxy log on failure
|
||||
if: failure()
|
||||
run: tail -n 300 proxy.log
|
||||
|
|
@ -19,7 +19,7 @@ Same thing for bug fixes. The tests should make it so that this specific bug can
|
|||
|
||||
End-to-end tests belong in `tests/e2e/` and must follow the harness conventions documented in that directory's `CLAUDE.md`
|
||||
|
||||
When creating PRs, don't set base to `main`. `litellm_internal_staging` serves that purpose for internal contributors; external / OSS contributions target the current daily OSS branch instead, named `litellm_oss_daily_YYYY_MM_DD` (a fresh one is cut each weekday, so use the most recent)
|
||||
When creating PRs, don't set base to `main`. `litellm_internal_staging` is the default base branch and serves that purpose for both internal and external / OSS contributions
|
||||
|
||||
When writing a PR body, treat the comments and imperative instructions inside @.github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule
|
||||
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ npm run build
|
|||
## Submitting Your PR
|
||||
|
||||
1. **Push your branch**: `git push origin your-feature-branch`
|
||||
2. **Create a PR**: Go to GitHub and open a pull request against the current daily OSS branch, named `litellm_oss_daily_YYYY_MM_DD`. A fresh one is cut each weekday, so pick the most recent from the [branch list](https://github.com/BerriAI/litellm/branches/all?query=litellm_oss_daily). Do not target `main`.
|
||||
2. **Create a PR**: Go to GitHub and open a pull request against [`litellm_internal_staging`](https://github.com/BerriAI/litellm/tree/litellm_internal_staging), which is the default base branch. Do not target `main`.
|
||||
3. **Fill out the PR template**: Provide clear description of changes
|
||||
4. **Wait for review**: Maintainers will review and provide feedback
|
||||
5. **Address feedback**: Make requested changes and push updates
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
|
|||
"/team/",
|
||||
"/v2/team/",
|
||||
"/organization/",
|
||||
"/v2/organization/",
|
||||
"/customer/",
|
||||
"/end_user/",
|
||||
"/sso/",
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
|
|||
UV_LINK_MODE=copy \
|
||||
PATH="/app/.venv/bin:${PATH}" \
|
||||
LITELLM_NON_ROOT=true \
|
||||
PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \
|
||||
XDG_CACHE_HOME=/app/.cache
|
||||
|
||||
# Copy dependency metadata first for layer caching
|
||||
|
|
@ -106,7 +105,9 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
|
|||
--python python3; \
|
||||
fi
|
||||
|
||||
RUN prisma generate --schema=./schema.prisma
|
||||
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
|
||||
npm_config_cache=/root/.npm \
|
||||
prisma generate --schema=./schema.prisma
|
||||
|
||||
RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \
|
||||
sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh
|
||||
|
|
@ -127,8 +128,6 @@ RUN for i in 1 2 3; do \
|
|||
# the rest of the builder's /app is source and build metadata that must not
|
||||
# ship (manifest-scanning tools attribute everything in it to this image).
|
||||
# entrypoint.sh invokes litellm/proxy/prisma_migration.py by source path.
|
||||
# Prisma caches live under /app/.cache here (XDG_CACHE_HOME /
|
||||
# PRISMA_BINARY_CACHE_DIR) so the runtime prisma generate finds them.
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY --from=builder /app/docker /app/docker
|
||||
COPY --from=builder /app/schema.prisma /app/schema.prisma
|
||||
|
|
@ -138,21 +137,35 @@ COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/pr
|
|||
# enterprise.enterprise_hooks from it)
|
||||
COPY --from=builder /app/enterprise /app/enterprise
|
||||
COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras
|
||||
COPY --from=builder /app/.cache /app/.cache
|
||||
# Prisma CLI + engines are baked under /opt/prisma, a fixed path every runtime
|
||||
# uid can read and that no cache volume mount shadows (unlike /app/.cache or
|
||||
# $HOME/.cache under readOnlyRootFilesystem + emptyDir or arbitrary-uid setups).
|
||||
# PRISMA_CLI_QUERY_ENGINE_TYPE=binary makes the CLI use the baked binary query
|
||||
# engine directly, so `prisma migrate deploy` on a fresh database needs no npm
|
||||
# and no network access; without it the CLI looks for the library engine, which
|
||||
# prisma stopped baking, and falls back to a download that fails offline or as a
|
||||
# non-writable uid (#33650, #24554).
|
||||
COPY --from=builder /opt/prisma /opt/prisma
|
||||
COPY --from=builder /var/lib/litellm/ui /var/lib/litellm/ui
|
||||
COPY --from=builder /var/lib/litellm/assets /var/lib/litellm/assets
|
||||
|
||||
# XDG_CACHE_HOME is intentionally left unset so it falls back to $HOME/.cache
|
||||
# (/app/.cache, writable by the runtime uid). The prisma bake at the read-only
|
||||
# /opt/prisma is anchored by PRISMA_BINARY_CACHE_DIR / PRISMA_CLI_PATH, so
|
||||
# nothing needs XDG to point there; pointing it at the read-only bake would
|
||||
# deny any XDG-aware library that writes a cache at runtime.
|
||||
ENV PATH="/app/.venv/bin:${PATH}" \
|
||||
PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \
|
||||
PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
|
||||
PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
HOME=/app \
|
||||
LITELLM_NON_ROOT=true \
|
||||
XDG_CACHE_HOME=/app/.cache \
|
||||
PRISMA_SKIP_POSTINSTALL_GENERATE=1 \
|
||||
PRISMA_HIDE_UPDATE_MESSAGE=1 \
|
||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 \
|
||||
PRISMA_OFFLINE_MODE=true
|
||||
|
||||
RUN mkdir -p /nonexistent /var/lib/litellm/assets /var/lib/litellm/ui && \
|
||||
RUN mkdir -p /nonexistent /app/.cache /var/lib/litellm/assets /var/lib/litellm/ui && \
|
||||
chown -R nobody:nogroup /app /var/lib/litellm/ui /var/lib/litellm/assets /nonexistent && \
|
||||
PRISMA_PATH=$(python -c "import os, prisma; print(os.path.dirname(prisma.__file__))") && \
|
||||
chown -R nobody:nogroup "$PRISMA_PATH" && \
|
||||
|
|
@ -165,12 +178,14 @@ RUN mkdir -p /nonexistent /var/lib/litellm/assets /var/lib/litellm/ui && \
|
|||
[ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g=u "$LITELLM_PROXY_EXTRAS_PATH" || true && \
|
||||
chmod -R g+w "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets && \
|
||||
[ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g+w "$LITELLM_PROXY_EXTRAS_PATH" || true && \
|
||||
chmod -R g+rX "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets /app/.cache
|
||||
chmod -R g+rX "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets && \
|
||||
chmod -R a+rX /opt/prisma && \
|
||||
test -x /opt/prisma/binaries/node_modules/.bin/prisma && \
|
||||
test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \
|
||||
ls /opt/prisma/binaries/node_modules/@prisma/engines/query-engine-* >/dev/null 2>&1
|
||||
|
||||
USER 65534
|
||||
|
||||
RUN prisma generate --schema=./schema.prisma
|
||||
|
||||
EXPOSE 4000/tcp
|
||||
|
||||
ENTRYPOINT ["/app/docker/prod_entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "LiteLLM_SSOIdentityAssertion" (
|
||||
"user_id" TEXT NOT NULL,
|
||||
"assertion_b64" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LiteLLM_SSOIdentityAssertion_pkey" PRIMARY KEY ("user_id")
|
||||
);
|
||||
|
|
@ -403,6 +403,15 @@ model LiteLLM_MCPServerOAuthClient {
|
|||
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
}
|
||||
|
||||
// The enterprise IdP identity assertion captured at SSO login, one row per user.
|
||||
// assertion_b64 is an encrypted JSON payload: {id_token, refresh_token?, issuer?, expires_at?}.
|
||||
model LiteLLM_SSOIdentityAssertion {
|
||||
user_id String @id
|
||||
assertion_b64 String
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
}
|
||||
|
||||
// Generate Tokens for Proxy
|
||||
model LiteLLM_VerificationToken {
|
||||
token String @id
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.79"
|
||||
version = "0.4.80"
|
||||
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.79"
|
||||
version = "0.4.80"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ Not allowed in `core`:
|
|||
|
||||
Python owns rollout state and fallback while Rust is being introduced. Rust
|
||||
paths must be off by default until parity tests prove equivalence with Python.
|
||||
A new provider/route may instead be implemented rust-only with no Python
|
||||
reference; then the Python interface is a thin dispatch that calls Rust with no
|
||||
fallback, and you state the rust-only choice explicitly in the PR. Either way
|
||||
the Python side stays minimal (it only marshals inputs and calls the Rust
|
||||
interface), never add a per-route feature flag, and never push provider
|
||||
dispatch into `litellm/main.py`; put it in a thin dispatch class under
|
||||
`litellm/llms/<provider>/<route>/`.
|
||||
|
||||
## Production Bar
|
||||
|
||||
|
|
|
|||
454
litellm-rust/Cargo.lock
generated
454
litellm-rust/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +7,8 @@ members = [
|
|||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/BerriAI/litellm"
|
||||
|
||||
|
|
|
|||
|
|
@ -39,11 +39,17 @@ Rules for adding or changing an LLM provider/route in `litellm-rust`. OCR (`MIST
|
|||
|
||||
19. Every provider transform ships tests for: supported-param filtering, request body shape, response normalization, missing/null fields, bad input, and `*_match_python` fixture parity.
|
||||
20. Lifecycle/hook tests cover hook order, success + failure callback payloads, pre-call guardrail blocking before any provider I/O, during-call body mutation, and provider-error mapping.
|
||||
21. Rust paths stay off by default and behind Python parity tests (disabled / enabled-equals-Python / bridge-unavailable fallback) until parity is proven.
|
||||
21. When a route has a Python reference implementation, the Rust path stays off by default and behind Python parity tests (disabled / enabled-equals-Python / bridge-unavailable fallback) until parity is proven. A new provider/route may instead be implemented rust-only with no Python reference; then the Python interface is a thin dispatch to Rust with no fallback, and tests cover the rust-backed path plus the unavailable-bridge error. State the rust-only choice explicitly in the PR.
|
||||
|
||||
## Python bridge (SDK side)
|
||||
|
||||
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>`.
|
||||
|
||||
## Checks before push
|
||||
|
||||
22. Run, and keep green:
|
||||
25. Run, and keep green:
|
||||
```bash
|
||||
cd litellm-rust
|
||||
cargo fmt --check
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ path = "src/main.rs"
|
|||
required-features = ["server"]
|
||||
|
||||
[dependencies]
|
||||
litellm-core.workspace = true
|
||||
litellm-core = { workspace = true, features = ["bedrock-auth"] }
|
||||
# reqwest (rustls + json) is used by io/ocr and ships realtime logs to the
|
||||
# Python proxy callbacks API.
|
||||
reqwest.workspace = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::audio_transcription::transformation::AudioTranscriptionProviderConfig;
|
||||
use litellm_core::error::CoreError;
|
||||
use litellm_core::providers::bedrock::audio_transcription::BEDROCK_AUDIO_TRANSCRIPTION_CONFIG;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(super) fn audio_transcription_provider_config(
|
||||
provider: &str,
|
||||
) -> Option<&'static dyn AudioTranscriptionProviderConfig> {
|
||||
match provider {
|
||||
"bedrock" => Some(&BEDROCK_AUDIO_TRANSCRIPTION_CONFIG),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn string_headers(
|
||||
headers: Option<Map<String, Value>>,
|
||||
) -> CoreResult<BTreeMap<String, String>> {
|
||||
headers
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
value
|
||||
.as_str()
|
||||
.map(|value| (key.clone(), value.to_string()))
|
||||
.ok_or_else(|| {
|
||||
CoreError::InvalidRequest(format!(
|
||||
"audio transcription extra_headers.{key} must be a string"
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn has_header(headers: &BTreeMap<String, String>, name: &str) -> bool {
|
||||
headers.keys().any(|key| key.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
pub(super) fn truncate_error_body(body: &str) -> String {
|
||||
let truncated: String = body.chars().take(256).collect();
|
||||
if truncated.chars().count() == body.chars().count() {
|
||||
truncated
|
||||
} else {
|
||||
format!("{truncated}... (truncated)")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
use std::time::SystemTime;
|
||||
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::audio_transcription::transformation::AudioTranscriptionAuth;
|
||||
use litellm_core::error::CoreError;
|
||||
use litellm_core::providers::bedrock::audio_transcription::aws_auth_config;
|
||||
use litellm_core::providers::bedrock::aws_base::{resolve_credentials, sign_bedrock_post};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::common_utils::truncate_error_body;
|
||||
use super::types::ProviderAudioTranscriptionRequest;
|
||||
use crate::client::http_client;
|
||||
|
||||
pub(crate) async fn execute_audio_transcription_provider_call(
|
||||
request: ProviderAudioTranscriptionRequest,
|
||||
) -> CoreResult<Value> {
|
||||
let body = serde_json::to_vec(&request.body).map_err(|error| {
|
||||
CoreError::InvalidRequest(format!("invalid audio request body: {error}"))
|
||||
})?;
|
||||
let mut request_builder = http_client().post(&request.url).body(body.clone());
|
||||
for (key, value) in &request.upstream_headers {
|
||||
request_builder = request_builder.header(key, value);
|
||||
}
|
||||
if let Some(duration) = request.timeout {
|
||||
request_builder = request_builder.timeout(duration);
|
||||
}
|
||||
let response = request_builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| CoreError::Network(error.to_string()))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| CoreError::Network(error.to_string()))?;
|
||||
if !status.is_success() {
|
||||
return Err(CoreError::Http {
|
||||
status: status.as_u16(),
|
||||
body: truncate_error_body(&text),
|
||||
});
|
||||
}
|
||||
let response_json: Value = serde_json::from_str(&text).map_err(|error| {
|
||||
CoreError::InvalidResponse(format!("invalid audio response JSON: {error}"))
|
||||
})?;
|
||||
Ok(request
|
||||
.config
|
||||
.transform_transcription_response(&request.model, response_json)?
|
||||
.into_json())
|
||||
}
|
||||
|
||||
pub(crate) async fn sign_request(
|
||||
request: &ProviderAudioTranscriptionRequest,
|
||||
optional_params: &serde_json::Map<String, Value>,
|
||||
) -> CoreResult<ProviderAudioTranscriptionRequest> {
|
||||
let env_lookup = environment_lookup;
|
||||
let auth = request
|
||||
.config
|
||||
.auth_strategy(&request.model, optional_params, &env_lookup)?;
|
||||
let body = serde_json::to_vec(&request.body).map_err(|error| {
|
||||
CoreError::InvalidRequest(format!("invalid audio request body: {error}"))
|
||||
})?;
|
||||
let mut headers = super::common_utils::string_headers(None)?;
|
||||
headers.insert("Content-Type".to_string(), "application/json".to_string());
|
||||
headers.extend(request.upstream_headers.iter().cloned());
|
||||
match auth {
|
||||
AudioTranscriptionAuth::Bearer => {}
|
||||
AudioTranscriptionAuth::AwsSigV4 { region, .. } => {
|
||||
let credentials =
|
||||
resolve_credentials(aws_auth_config(optional_params, &env_lookup), &env_lookup)
|
||||
.await?;
|
||||
headers.extend(sign_bedrock_post(
|
||||
&request.url,
|
||||
&body,
|
||||
&headers,
|
||||
®ion,
|
||||
&credentials,
|
||||
SystemTime::now(),
|
||||
)?);
|
||||
}
|
||||
}
|
||||
Ok(ProviderAudioTranscriptionRequest {
|
||||
upstream_headers: headers.into_iter().collect(),
|
||||
..request.clone()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn environment_lookup(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok()
|
||||
}
|
||||
300
litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs
Normal file
300
litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::audio_transcription::transformation::AudioTranscriptionAuth;
|
||||
use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming};
|
||||
use litellm_core::error::CoreError;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::common_utils::{audio_transcription_provider_config, has_header, string_headers};
|
||||
use super::handler::sign_request;
|
||||
use super::types::{PreparedAudioTranscriptionRequest, ProviderAudioTranscriptionRequest};
|
||||
use crate::integrations::custom_guardrail::{
|
||||
CustomGuardrailRunner, GuardrailContext, GuardrailError, GuardrailRequest,
|
||||
};
|
||||
use crate::integrations::custom_logger::{
|
||||
CallType, CallbackTiming, CallbackValue, CustomLoggerRunner, LoggingError, ModelCallDetails,
|
||||
};
|
||||
use crate::integrations::types::{
|
||||
RequestMetadata, StandardLoggingMetadata, StandardLoggingPayload,
|
||||
};
|
||||
|
||||
pub(crate) struct AudioTranscriptionLifecycleHooks {
|
||||
logger_runner: CustomLoggerRunner,
|
||||
guardrail_runner: CustomGuardrailRunner,
|
||||
request_metadata: RequestMetadata,
|
||||
}
|
||||
|
||||
type AudioFuture<'a, T> = Pin<Box<dyn Future<Output = CoreResult<T>> + Send + 'a>>;
|
||||
type AudioLogFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
|
||||
|
||||
impl AudioTranscriptionLifecycleHooks {
|
||||
pub(crate) fn new(
|
||||
logger_runner: CustomLoggerRunner,
|
||||
guardrail_runner: CustomGuardrailRunner,
|
||||
request_metadata: RequestMetadata,
|
||||
) -> Self {
|
||||
Self {
|
||||
logger_runner,
|
||||
guardrail_runner,
|
||||
request_metadata,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_pre_call_guardrails(
|
||||
&self,
|
||||
request: PreparedAudioTranscriptionRequest,
|
||||
) -> CoreResult<PreparedAudioTranscriptionRequest> {
|
||||
if self.guardrail_runner.is_empty() {
|
||||
return Ok(request);
|
||||
}
|
||||
let (guardrail_request, _) = self
|
||||
.guardrail_runner
|
||||
.run_pre_call(
|
||||
&guardrail_context(&self.request_metadata),
|
||||
GuardrailRequest::new(json!({
|
||||
"model": request.model,
|
||||
"custom_llm_provider": request.custom_llm_provider,
|
||||
"audio": request.audio,
|
||||
"optional_params": request.optional_params,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.map_err(guardrail_error_to_core_error)?;
|
||||
let Value::Object(mut data) = guardrail_request.data else {
|
||||
return Err(CoreError::InvalidRequest(
|
||||
"audio transcription pre_call guardrail must return an object".to_string(),
|
||||
));
|
||||
};
|
||||
let audio = data.remove("audio").ok_or_else(|| {
|
||||
CoreError::InvalidRequest("audio transcription guardrail removed audio".to_string())
|
||||
})?;
|
||||
let optional_params = match data.remove("optional_params") {
|
||||
Some(Value::Object(value)) => value,
|
||||
Some(_) => {
|
||||
return Err(CoreError::InvalidRequest(
|
||||
"audio transcription optional_params must be an object".to_string(),
|
||||
));
|
||||
}
|
||||
None => Map::new(),
|
||||
};
|
||||
Ok(PreparedAudioTranscriptionRequest {
|
||||
audio,
|
||||
optional_params,
|
||||
..request
|
||||
})
|
||||
}
|
||||
|
||||
async fn prepare_provider_request(
|
||||
&self,
|
||||
request: PreparedAudioTranscriptionRequest,
|
||||
) -> CoreResult<ProviderAudioTranscriptionRequest> {
|
||||
let config = audio_transcription_provider_config(&request.custom_llm_provider)
|
||||
.ok_or_else(|| CoreError::InvalidProvider(request.custom_llm_provider.clone()))?;
|
||||
let env_lookup = super::handler::environment_lookup;
|
||||
let headers = string_headers(request.extra_headers)?;
|
||||
let url = config.complete_url(
|
||||
request.api_base.as_deref(),
|
||||
&request.model,
|
||||
&request.optional_params,
|
||||
&env_lookup,
|
||||
)?;
|
||||
let filtered_params = config.map_transcription_params(&request.optional_params);
|
||||
let body = config.transform_transcription_request(
|
||||
&request.model,
|
||||
request.audio,
|
||||
filtered_params,
|
||||
)?;
|
||||
let auth = config.auth_strategy(&request.model, &request.optional_params, &env_lookup)?;
|
||||
let mut upstream_headers = headers.into_iter().collect::<Vec<_>>();
|
||||
if matches!(auth, AudioTranscriptionAuth::Bearer)
|
||||
&& !has_header(
|
||||
&upstream_headers
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<std::collections::BTreeMap<_, _>>(),
|
||||
"authorization",
|
||||
)
|
||||
&& let Some(api_key) = request.api_key.as_deref()
|
||||
{
|
||||
upstream_headers.push(("Authorization".to_string(), format!("Bearer {api_key}")));
|
||||
}
|
||||
let provider_request = ProviderAudioTranscriptionRequest {
|
||||
model: request.model,
|
||||
config,
|
||||
url,
|
||||
body: body.body,
|
||||
upstream_headers,
|
||||
timeout: request.timeout,
|
||||
};
|
||||
let provider_request = self.run_during_call_guardrails(provider_request).await?;
|
||||
sign_request(&provider_request, &request.optional_params).await
|
||||
}
|
||||
|
||||
async fn run_during_call_guardrails(
|
||||
&self,
|
||||
request: ProviderAudioTranscriptionRequest,
|
||||
) -> CoreResult<ProviderAudioTranscriptionRequest> {
|
||||
if self.guardrail_runner.is_empty() {
|
||||
return Ok(request);
|
||||
}
|
||||
let (guardrail_request, _) = self
|
||||
.guardrail_runner
|
||||
.run_during_call(
|
||||
&guardrail_context(&self.request_metadata),
|
||||
GuardrailRequest::new(json!({
|
||||
"model": request.model,
|
||||
"custom_llm_provider": "bedrock",
|
||||
"url": request.url,
|
||||
"body": request.body,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.map_err(guardrail_error_to_core_error)?;
|
||||
let Value::Object(mut data) = guardrail_request.data else {
|
||||
return Err(CoreError::InvalidRequest(
|
||||
"audio transcription during_call guardrail must return an object".to_string(),
|
||||
));
|
||||
};
|
||||
let body = data.remove("body").ok_or_else(|| {
|
||||
CoreError::InvalidRequest("audio transcription guardrail removed body".to_string())
|
||||
})?;
|
||||
Ok(ProviderAudioTranscriptionRequest { body, ..request })
|
||||
}
|
||||
|
||||
fn logging_payload(
|
||||
&self,
|
||||
context: &CallLifecycleContext,
|
||||
timing: &CallLifecycleTiming,
|
||||
) -> StandardLoggingPayload {
|
||||
StandardLoggingPayload {
|
||||
id: context.litellm_call_id.clone(),
|
||||
litellm_call_id: context.litellm_call_id.clone(),
|
||||
call_type: context.call_type.clone(),
|
||||
model: context.model.clone(),
|
||||
custom_llm_provider: context.custom_llm_provider.clone(),
|
||||
response_cost: 0.0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
start_time: timing.start_time,
|
||||
end_time: timing.end_time,
|
||||
stream: false,
|
||||
metadata: StandardLoggingMetadata {
|
||||
user_api_key_hash: self.request_metadata.user_api_key_hash.clone(),
|
||||
user_api_key_user_id: self.request_metadata.user_api_key_user_id.clone(),
|
||||
user_api_key_team_id: self.request_metadata.user_api_key_team_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
messages: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CallLifecycleHooks<PreparedAudioTranscriptionRequest, ProviderAudioTranscriptionRequest, Value>
|
||||
for AudioTranscriptionLifecycleHooks
|
||||
{
|
||||
type PreCallFuture<'a> = AudioFuture<'a, PreparedAudioTranscriptionRequest>;
|
||||
type DuringCallFuture<'a> = AudioFuture<'a, ProviderAudioTranscriptionRequest>;
|
||||
type SuccessFuture<'a> = AudioLogFuture<'a>;
|
||||
type FailureFuture<'a> = AudioLogFuture<'a>;
|
||||
|
||||
fn async_pre_call_hook<'a>(
|
||||
&'a self,
|
||||
_context: &'a CallLifecycleContext,
|
||||
request: PreparedAudioTranscriptionRequest,
|
||||
) -> Self::PreCallFuture<'a> {
|
||||
Box::pin(async move { self.run_pre_call_guardrails(request).await })
|
||||
}
|
||||
|
||||
fn async_during_call_hook<'a>(
|
||||
&'a self,
|
||||
_context: &'a CallLifecycleContext,
|
||||
request: PreparedAudioTranscriptionRequest,
|
||||
) -> Self::DuringCallFuture<'a> {
|
||||
Box::pin(async move { self.prepare_provider_request(request).await })
|
||||
}
|
||||
|
||||
fn async_log_success_event<'a>(
|
||||
&'a self,
|
||||
context: &'a CallLifecycleContext,
|
||||
response: &'a Value,
|
||||
timing: &'a CallLifecycleTiming,
|
||||
) -> Self::SuccessFuture<'a> {
|
||||
Box::pin(async move {
|
||||
if self.logger_runner.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.logger_runner
|
||||
.async_log_success_event(
|
||||
&ModelCallDetails::from_standard_logging_payload(
|
||||
self.logging_payload(context, timing),
|
||||
),
|
||||
&CallbackValue::new("audio_transcription", response.clone()),
|
||||
CallbackTiming::new(timing.start_time, timing.end_time),
|
||||
)
|
||||
.await;
|
||||
})
|
||||
}
|
||||
|
||||
fn async_log_failure_event<'a>(
|
||||
&'a self,
|
||||
context: &'a CallLifecycleContext,
|
||||
error: &'a CoreError,
|
||||
timing: &'a CallLifecycleTiming,
|
||||
) -> Self::FailureFuture<'a> {
|
||||
Box::pin(async move {
|
||||
if self.logger_runner.is_empty() {
|
||||
return;
|
||||
}
|
||||
let logging_error = LoggingError {
|
||||
message: error.to_string(),
|
||||
kind: core_error_kind(error).to_string(),
|
||||
};
|
||||
self.logger_runner
|
||||
.async_log_failure_event(
|
||||
&ModelCallDetails::from_standard_logging_payload(
|
||||
self.logging_payload(context, timing),
|
||||
)
|
||||
.with_failure_error(logging_error.clone()),
|
||||
Some(&CallbackValue::new(
|
||||
"error",
|
||||
json!({"message": logging_error.message, "kind": logging_error.kind}),
|
||||
)),
|
||||
CallbackTiming::new(timing.start_time, timing.end_time),
|
||||
)
|
||||
.await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn guardrail_context(metadata: &RequestMetadata) -> GuardrailContext {
|
||||
GuardrailContext {
|
||||
call_type: CallType::Other("audio_transcription".to_string()),
|
||||
selected_guardrails: Vec::new(),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
user_api_key_hash: metadata.user_api_key_hash.clone(),
|
||||
user_api_key_user_id: metadata.user_api_key_user_id.clone(),
|
||||
user_api_key_team_id: metadata.user_api_key_team_id.clone(),
|
||||
trace_parent: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn guardrail_error_to_core_error(error: GuardrailError) -> CoreError {
|
||||
CoreError::InvalidRequest(format!("{}: {}", error.kind, error.message))
|
||||
}
|
||||
|
||||
fn core_error_kind(error: &CoreError) -> &'static str {
|
||||
match error {
|
||||
CoreError::Auth(_) => "AuthError",
|
||||
CoreError::InvalidProvider(_) => "InvalidProvider",
|
||||
CoreError::InvalidRequest(_) => "InvalidRequest",
|
||||
CoreError::InvalidType { .. } => "InvalidType",
|
||||
CoreError::MissingField(_) => "MissingField",
|
||||
CoreError::Http { .. } => "HttpError",
|
||||
CoreError::InvalidResponse(_) => "InvalidResponse",
|
||||
CoreError::Network(_) => "NetworkError",
|
||||
CoreError::Routing(_) => "RoutingError",
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
use litellm_core::CoreResult;
|
||||
use litellm_core::call_lifecycle::CallLifecycle;
|
||||
use serde_json::Value;
|
||||
|
||||
mod common_utils;
|
||||
mod handler;
|
||||
mod hooks;
|
||||
mod prepare;
|
||||
mod types;
|
||||
|
||||
pub use types::AudioTranscriptionRequest;
|
||||
|
||||
use handler::execute_audio_transcription_provider_call;
|
||||
use prepare::{PreparedAudioTranscriptionCall, prepare_audio_transcription_call};
|
||||
|
||||
pub async fn audio_transcription(request: AudioTranscriptionRequest<'_>) -> CoreResult<Value> {
|
||||
let PreparedAudioTranscriptionCall { request, hooks } =
|
||||
prepare_audio_transcription_call(request);
|
||||
CallLifecycle::default()
|
||||
.run_request(request, &hooks, execute_audio_transcription_provider_call)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use litellm_core::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider};
|
||||
|
||||
use super::hooks::AudioTranscriptionLifecycleHooks;
|
||||
use super::types::{AudioTranscriptionRequest, PreparedAudioTranscriptionRequest};
|
||||
use crate::integrations::custom_guardrail::CustomGuardrailRunner;
|
||||
use crate::integrations::custom_logger::CustomLoggerRunner;
|
||||
|
||||
pub(crate) struct PreparedAudioTranscriptionCall {
|
||||
pub(crate) request: PreparedAudioTranscriptionRequest,
|
||||
pub(crate) hooks: AudioTranscriptionLifecycleHooks,
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_audio_transcription_call(
|
||||
request: AudioTranscriptionRequest<'_>,
|
||||
) -> PreparedAudioTranscriptionCall {
|
||||
let call_id = request
|
||||
.litellm_call_id
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(new_audio_transcription_call_id);
|
||||
let provider_info = get_custom_llm_provider(request.model, request.custom_llm_provider)
|
||||
.unwrap_or(CustomLlmProvider {
|
||||
model: request.model,
|
||||
custom_llm_provider: "bedrock",
|
||||
});
|
||||
PreparedAudioTranscriptionCall {
|
||||
request: PreparedAudioTranscriptionRequest {
|
||||
model: provider_info.model.to_string(),
|
||||
custom_llm_provider: provider_info.custom_llm_provider.to_string(),
|
||||
litellm_call_id: call_id,
|
||||
audio: request.audio,
|
||||
api_key: request.api_key.map(str::to_string),
|
||||
api_base: request.api_base.map(str::to_string),
|
||||
extra_headers: request.extra_headers,
|
||||
optional_params: request.optional_params,
|
||||
timeout: request.timeout,
|
||||
},
|
||||
hooks: AudioTranscriptionLifecycleHooks::new(
|
||||
CustomLoggerRunner::new(request.callbacks),
|
||||
CustomGuardrailRunner::new(request.guardrails),
|
||||
request.request_metadata,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_audio_transcription_call_id() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
let sequence = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_nanos());
|
||||
format!("audio-transcription-{timestamp}-{sequence}")
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
use serde_json::{Map, json};
|
||||
|
||||
use super::{AudioTranscriptionRequest, audio_transcription};
|
||||
|
||||
#[tokio::test]
|
||||
async fn bedrock_request_is_signed_and_contains_audio() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("listener");
|
||||
let address = listener.local_addr().expect("address");
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("connection");
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 16_384];
|
||||
let count = stream.read(&mut buffer).expect("request");
|
||||
request.extend_from_slice(&buffer[..count]);
|
||||
let request = String::from_utf8_lossy(&request);
|
||||
assert!(request.contains("POST /model/mistral.voxtral-mini-3b-2507/converse"));
|
||||
assert!(request.contains("authorization: AWS4-HMAC-SHA256"));
|
||||
assert!(request.contains("x-amz-date:"));
|
||||
assert!(request.contains("\"bytes\":\"AQI=\""));
|
||||
assert!(request.contains("Transcribe the audio. Respond with only the transcript."));
|
||||
let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 53\r\nConnection: close\r\n\r\n{\"output\":{\"message\":{\"content\":[{\"text\":\"hello\"}]}}}";
|
||||
stream.write_all(response).expect("response");
|
||||
});
|
||||
|
||||
let optional_params = Map::from_iter([
|
||||
("aws_access_key_id".to_string(), json!("access-key")),
|
||||
("aws_secret_access_key".to_string(), json!("secret-key")),
|
||||
("aws_region_name".to_string(), json!("us-east-1")),
|
||||
]);
|
||||
let api_base = format!("http://{address}");
|
||||
let response = audio_transcription(AudioTranscriptionRequest {
|
||||
model: "mistral.voxtral-mini-3b-2507",
|
||||
audio: json!({"data": "AQI=", "format": "wav", "filename": "audio.wav"}),
|
||||
api_key: None,
|
||||
api_base: Some(&api_base),
|
||||
custom_llm_provider: Some("bedrock"),
|
||||
extra_headers: None,
|
||||
optional_params,
|
||||
timeout: None,
|
||||
callbacks: Vec::new(),
|
||||
guardrails: Vec::new(),
|
||||
request_metadata: Default::default(),
|
||||
litellm_call_id: None,
|
||||
})
|
||||
.await
|
||||
.expect("transcription");
|
||||
assert_eq!(response, json!({"text": "hello"}));
|
||||
server.join().expect("server");
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use litellm_core::audio_transcription::transformation::AudioTranscriptionProviderConfig;
|
||||
use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleRequest};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::integrations::custom_guardrail::CustomGuardrail;
|
||||
use crate::integrations::custom_logger::CustomLogger;
|
||||
use crate::integrations::types::RequestMetadata;
|
||||
|
||||
pub struct AudioTranscriptionRequest<'a> {
|
||||
pub model: &'a str,
|
||||
pub audio: Value,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub api_base: Option<&'a str>,
|
||||
pub custom_llm_provider: Option<&'a str>,
|
||||
pub extra_headers: Option<Map<String, Value>>,
|
||||
pub optional_params: Map<String, Value>,
|
||||
pub timeout: Option<Duration>,
|
||||
pub callbacks: Vec<Arc<dyn CustomLogger>>,
|
||||
pub guardrails: Vec<Arc<dyn CustomGuardrail>>,
|
||||
pub request_metadata: RequestMetadata,
|
||||
pub litellm_call_id: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub(crate) struct PreparedAudioTranscriptionRequest {
|
||||
pub(crate) model: String,
|
||||
pub(crate) custom_llm_provider: String,
|
||||
pub(crate) litellm_call_id: String,
|
||||
pub(crate) audio: Value,
|
||||
pub(crate) api_key: Option<String>,
|
||||
pub(crate) api_base: Option<String>,
|
||||
pub(crate) extra_headers: Option<Map<String, Value>>,
|
||||
pub(crate) optional_params: Map<String, Value>,
|
||||
pub(crate) timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl CallLifecycleRequest for PreparedAudioTranscriptionRequest {
|
||||
fn lifecycle_context(&self) -> CallLifecycleContext {
|
||||
CallLifecycleContext::new(
|
||||
"audio_transcription",
|
||||
self.model.clone(),
|
||||
self.custom_llm_provider.clone(),
|
||||
self.litellm_call_id.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ProviderAudioTranscriptionRequest {
|
||||
pub(crate) model: String,
|
||||
pub(crate) config: &'static dyn AudioTranscriptionProviderConfig,
|
||||
pub(crate) url: String,
|
||||
pub(crate) body: Value,
|
||||
pub(crate) upstream_headers: Vec<(String, String)>,
|
||||
pub(crate) timeout: Option<Duration>,
|
||||
}
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
//! runs during extraction, before the handler body. Routes never re-implement it.
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::StatusCode;
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
const OCR_TIMEOUT_SECS: u64 = 600;
|
||||
const HTTP_CLIENT_TIMEOUT_SECS: u64 = 600;
|
||||
|
||||
pub(super) fn http_client() -> &'static reqwest::Client {
|
||||
pub(crate) fn http_client() -> &'static reqwest::Client {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(OCR_TIMEOUT_SECS))
|
||||
.timeout(Duration::from_secs(HTTP_CLIENT_TIMEOUT_SECS))
|
||||
.build()
|
||||
.expect("failed to build reqwest client")
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub use crate::audio_transcription::{AudioTranscriptionRequest, audio_transcription};
|
||||
|
|
@ -1 +1 @@
|
|||
pub use crate::messages::{messages, MessagesRequest};
|
||||
pub use crate::messages::{MessagesRequest, messages};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod audio_transcription;
|
||||
pub mod messages;
|
||||
pub mod ocr;
|
||||
pub mod realtime;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
pub use crate::ocr::{ocr, OcrRequest};
|
||||
pub use crate::ocr::{OcrRequest, ocr};
|
||||
|
|
|
|||
|
|
@ -15,16 +15,16 @@ use std::time::Duration;
|
|||
|
||||
use futures_util::stream::{SplitSink, SplitStream};
|
||||
use futures_util::{Sink, SinkExt, Stream, StreamExt};
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::error::CoreError;
|
||||
use litellm_core::realtime::transformation::RealtimeProviderConfig;
|
||||
use litellm_core::realtime::types::RealtimeEvent;
|
||||
use litellm_core::CoreResult;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||
|
||||
use litellm_core::providers::openai::realtime::transformation::OPENAI_REALTIME_CONFIG;
|
||||
|
||||
|
|
@ -113,7 +113,7 @@ pub(crate) async fn read_event(upstream_rx: &mut UpstreamRx) -> CoreResult<Realt
|
|||
Message::Close(_) => {
|
||||
return Err(CoreError::Network(
|
||||
"upstream closed before first event".to_string(),
|
||||
))
|
||||
));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use litellm_core::realtime::types::RealtimeEvent;
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::realtime::types::RealtimeEvent;
|
||||
|
||||
use crate::io::realtime::{
|
||||
dial_upstream, read_event, resolve_api_key, UpstreamRx, UpstreamTx, UpstreamWs,
|
||||
UpstreamRx, UpstreamTx, UpstreamWs, dial_upstream, read_event, resolve_api_key,
|
||||
};
|
||||
|
||||
/// Default target warm sockets per key when pooling is enabled.
|
||||
|
|
@ -473,8 +473,8 @@ pub fn upstream_key(
|
|||
/// unhealthy — we'd rather discard and fresh-dial than hand over a socket in an
|
||||
/// unexpected state. `Pending` (the healthy case) returns `false`.
|
||||
fn is_dead(rx: &mut UpstreamRx) -> bool {
|
||||
use futures_util::task::noop_waker_ref;
|
||||
use futures_util::Stream;
|
||||
use futures_util::task::noop_waker_ref;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
|
|
@ -523,15 +523,15 @@ mod tests {
|
|||
))
|
||||
.await;
|
||||
while let Some(Ok(msg)) = ws.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
if text.contains("response.create") {
|
||||
for frame in [
|
||||
r#"{"type":"response.created"}"#,
|
||||
r#"{"type":"response.output_audio.delta","delta":"AAAA"}"#,
|
||||
r#"{"type":"response.done"}"#,
|
||||
] {
|
||||
let _ = ws.send(Message::Text(frame.to_string())).await;
|
||||
}
|
||||
if let Message::Text(text) = msg
|
||||
&& text.contains("response.create")
|
||||
{
|
||||
for frame in [
|
||||
r#"{"type":"response.created"}"#,
|
||||
r#"{"type":"response.output_audio.delta","delta":"AAAA"}"#,
|
||||
r#"{"type":"response.done"}"#,
|
||||
] {
|
||||
let _ = ws.send(Message::Text(frame.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,19 +10,18 @@ use litellm_core::responses::websocket::ResponsesWebSocketProviderConfig;
|
|||
use litellm_core::{CoreError, CoreResult};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::header::{HeaderName, AUTHORIZATION};
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::http::header::{AUTHORIZATION, HeaderName};
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||
|
||||
use crate::constants::{
|
||||
DEFAULT_RESPONSES_WS_CONNECT_TIMEOUT_SECS, DEFAULT_RESPONSES_WS_IDLE_TIMEOUT_SECS,
|
||||
};
|
||||
|
||||
const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
|
||||
const MISSING_KEY_MESSAGE: &str =
|
||||
"Missing OpenAI API Key - a Responses WebSocket call is being made but no key was passed via params or the OPENAI_API_KEY environment variable";
|
||||
const MISSING_KEY_MESSAGE: &str = "Missing OpenAI API Key - a Responses WebSocket call is being made but no key was passed via params or the OPENAI_API_KEY environment variable";
|
||||
|
||||
pub type ResponsesUpstreamWs = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
type UpstreamTx = SplitSink<ResponsesUpstreamWs, Message>;
|
||||
|
|
@ -83,8 +82,8 @@ impl ResponsesWebSocketConnection {
|
|||
}
|
||||
|
||||
pub async fn recv_text(&self) -> CoreResult<Option<String>> {
|
||||
let mut socket = self.socket.lock().await;
|
||||
let Some(socket) = socket.as_mut() else {
|
||||
let mut socket_guard = self.socket.lock().await;
|
||||
let Some(socket) = socket_guard.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
match socket.next().await {
|
||||
|
|
@ -456,9 +455,11 @@ mod tests {
|
|||
assert_eq!(fourth.event_type, ResponsesWsEventType::ResponseCompleted);
|
||||
let observed: Vec<_> = observed_rx.collect().await;
|
||||
assert_eq!(observed.len(), 4);
|
||||
assert!(observed
|
||||
.iter()
|
||||
.all(|event| event.event_type != ResponsesWsEventType::ResponseCreate));
|
||||
assert!(
|
||||
observed
|
||||
.iter()
|
||||
.all(|event| event.event_type != ResponsesWsEventType::ResponseCreate)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
//! binary turns on. The `python-config` feature additionally pulls in [`python`]
|
||||
//! for the load-time config reader.
|
||||
|
||||
pub mod audio_transcription;
|
||||
mod client;
|
||||
pub mod io;
|
||||
pub mod messages;
|
||||
pub mod ocr;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use litellm_ai_gateway::io::realtime_pool::{upstream_key, PoolConfig, RealtimePool};
|
||||
use litellm_ai_gateway::io::realtime_pool::{PoolConfig, RealtimePool, upstream_key};
|
||||
use litellm_ai_gateway::routes;
|
||||
use litellm_ai_gateway::state::AppState;
|
||||
use litellm_core::router::{Deployment, LiteLLMParams, Router};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use litellm_core::error::{json_type_name, CoreError};
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::error::{CoreError, json_type_name};
|
||||
use litellm_core::messages::transformation::AnthropicMessagesProviderConfig;
|
||||
use litellm_core::providers::anthropic::messages::transformation::ANTHROPIC_MESSAGES_CONFIG;
|
||||
use litellm_core::providers::azure_ai::messages::transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG;
|
||||
use litellm_core::CoreResult;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::constants::MESSAGES_ERROR_BODY_MAX_CHARS;
|
||||
|
|
@ -50,3 +50,15 @@ pub(super) fn has_header(headers: &[(String, String)], name: &str) -> bool {
|
|||
.iter()
|
||||
.any(|(key, _)| key.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
pub(super) fn has_bearer_auth(headers: &[(String, String)]) -> bool {
|
||||
headers.iter().any(|(name, value)| {
|
||||
if !name.eq_ignore_ascii_case("authorization") {
|
||||
return false;
|
||||
}
|
||||
let value = value.trim();
|
||||
value.len() > 7
|
||||
&& value[..7].eq_ignore_ascii_case("bearer ")
|
||||
&& !value[7..].trim().is_empty()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use litellm_core::error::CoreError;
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::error::CoreError;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::client::http_client;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use litellm_core::messages::transformation::MessagesAuthStrategy;
|
||||
use litellm_core::routing_utils::provider::{get_custom_llm_provider, CustomLlmProvider};
|
||||
use litellm_core::CoreError;
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::messages::transformation::MessagesAuthStrategy;
|
||||
use litellm_core::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider};
|
||||
|
||||
use super::common_utils::{has_header, messages_provider_config, string_headers};
|
||||
use super::common_utils::{has_bearer_auth, has_header, messages_provider_config, string_headers};
|
||||
use super::types::{MessagesRequest, ProviderMessagesRequest};
|
||||
|
||||
pub(super) fn prepare_messages_call(
|
||||
|
|
@ -33,7 +33,9 @@ pub(super) fn prepare_messages_call(
|
|||
let mut headers = string_headers(request.extra_headers)?;
|
||||
|
||||
let auth_strategy = config.auth_strategy();
|
||||
if !has_header(&headers, auth_strategy.header_name()) {
|
||||
let already_authorized = has_header(&headers, auth_strategy.header_name())
|
||||
|| (config.accepts_bearer_auth() && has_bearer_auth(&headers));
|
||||
if !already_authorized {
|
||||
let api_key = config.resolve_api_key(request.api_key, &env_lookup)?;
|
||||
let auth_header = match auth_strategy {
|
||||
MessagesAuthStrategy::Bearer => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use litellm_core::error::CoreError;
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
use super::common_utils::{
|
||||
has_header, messages_provider_config, string_headers, truncate_error_body,
|
||||
has_bearer_auth, has_header, messages_provider_config, string_headers, truncate_error_body,
|
||||
};
|
||||
use super::{messages, MessagesRequest};
|
||||
use super::{MessagesRequest, messages};
|
||||
|
||||
async fn read_http_request(socket: &mut TcpStream) -> String {
|
||||
let mut request = Vec::new();
|
||||
|
|
@ -85,6 +85,34 @@ fn has_header_is_case_insensitive() {
|
|||
assert!(!has_header(&headers, "authorization"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_bearer_auth_requires_a_nonempty_bearer_token() {
|
||||
assert!(has_bearer_auth(&[(
|
||||
"Authorization".to_string(),
|
||||
"Bearer tok".to_string()
|
||||
)]));
|
||||
assert!(has_bearer_auth(&[(
|
||||
"authorization".to_string(),
|
||||
"bearer tok".to_string()
|
||||
)]));
|
||||
assert!(!has_bearer_auth(&[(
|
||||
"authorization".to_string(),
|
||||
"Bearer ".to_string()
|
||||
)]));
|
||||
assert!(!has_bearer_auth(&[(
|
||||
"authorization".to_string(),
|
||||
String::new()
|
||||
)]));
|
||||
assert!(!has_bearer_auth(&[(
|
||||
"authorization".to_string(),
|
||||
"Basic abc".to_string()
|
||||
)]));
|
||||
assert!(!has_bearer_auth(&[(
|
||||
"x-api-key".to_string(),
|
||||
"sk".to_string()
|
||||
)]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn messages_round_trip_builds_azure_request_and_passes_response_through() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds");
|
||||
|
|
@ -252,6 +280,112 @@ async fn messages_does_not_duplicate_auth_when_x_api_key_supplied() {
|
|||
assert!(!head.contains("rust-fallback-key"), "{head}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn messages_forwards_entra_id_bearer_without_requiring_api_key() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("accepts request");
|
||||
let request = read_http_request(&mut socket).await;
|
||||
let response_body =
|
||||
r#"{"id":"msg_3","type":"message","role":"assistant","content":[],"model":"m"}"#;
|
||||
socket
|
||||
.write_all(write_response(response_body).as_bytes())
|
||||
.await
|
||||
.expect("writes response");
|
||||
request
|
||||
});
|
||||
|
||||
let mut headers = Map::new();
|
||||
headers.insert(
|
||||
"Authorization".to_string(),
|
||||
Value::String("Bearer entra-token".to_string()),
|
||||
);
|
||||
|
||||
messages(MessagesRequest {
|
||||
model: "claude-sonnet-4-5",
|
||||
body: json!({"model": "claude-sonnet-4-5", "max_tokens": 8, "messages": []}),
|
||||
api_key: None,
|
||||
api_base: Some(&format!("http://{addr}")),
|
||||
custom_llm_provider: Some("azure_ai"),
|
||||
extra_headers: Some(headers),
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
})
|
||||
.await
|
||||
.expect("entra id request succeeds without api key");
|
||||
|
||||
let request = server.await.expect("server task completes");
|
||||
let head = request
|
||||
.split_once("\r\n\r\n")
|
||||
.expect("has body")
|
||||
.0
|
||||
.to_ascii_lowercase();
|
||||
assert!(head.contains("authorization: bearer entra-token"), "{head}");
|
||||
assert!(!head.contains("x-api-key"), "{head}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn messages_requires_auth_when_no_key_and_no_header() {
|
||||
let err = messages(MessagesRequest {
|
||||
model: "claude-sonnet-4-5",
|
||||
body: json!({"model": "claude-sonnet-4-5", "max_tokens": 8, "messages": []}),
|
||||
api_key: None,
|
||||
api_base: Some("http://127.0.0.1:1"),
|
||||
custom_llm_provider: Some("azure_ai"),
|
||||
extra_headers: None,
|
||||
timeout: Some(Duration::from_millis(50)),
|
||||
})
|
||||
.await
|
||||
.expect_err("missing auth errors");
|
||||
|
||||
assert!(matches!(err, CoreError::Auth(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn messages_ignores_malformed_authorization_and_uses_api_key() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("accepts request");
|
||||
let request = read_http_request(&mut socket).await;
|
||||
let response_body =
|
||||
r#"{"id":"msg_4","type":"message","role":"assistant","content":[],"model":"m"}"#;
|
||||
socket
|
||||
.write_all(write_response(response_body).as_bytes())
|
||||
.await
|
||||
.expect("writes response");
|
||||
request
|
||||
});
|
||||
|
||||
let mut headers = Map::new();
|
||||
headers.insert(
|
||||
"Authorization".to_string(),
|
||||
Value::String("Bearer ".to_string()),
|
||||
);
|
||||
|
||||
messages(MessagesRequest {
|
||||
model: "claude-sonnet-4-5",
|
||||
body: json!({"model": "claude-sonnet-4-5", "max_tokens": 8, "messages": []}),
|
||||
api_key: Some("sk-azure"),
|
||||
api_base: Some(&format!("http://{addr}")),
|
||||
custom_llm_provider: Some("azure_ai"),
|
||||
extra_headers: Some(headers),
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
})
|
||||
.await
|
||||
.expect("falls back to api key");
|
||||
|
||||
let request = server.await.expect("server task completes");
|
||||
let head = request
|
||||
.split_once("\r\n\r\n")
|
||||
.expect("has body")
|
||||
.0
|
||||
.to_ascii_lowercase();
|
||||
assert!(head.contains("x-api-key: sk-azure"), "{head}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn messages_maps_provider_error_status_to_http_error() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds");
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::error::CoreError;
|
||||
use litellm_core::ocr::transformation::OcrProviderConfig;
|
||||
use litellm_core::CoreResult;
|
||||
use reqwest::Url;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ use litellm_core::providers::vertex_ai::ocr::transformation::{
|
|||
VERTEX_AI_DEEPSEEK_OCR_CONFIG, VERTEX_AI_OCR_CONFIG,
|
||||
};
|
||||
|
||||
use super::client::http_client;
|
||||
use crate::client::http_client;
|
||||
|
||||
const ERROR_BODY_MAX_CHARS: usize = 256;
|
||||
const AZURE_DOCUMENT_INTELLIGENCE_POLL_TIMEOUT_SECS: u64 = 120;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use litellm_core::CoreResult;
|
||||
use litellm_core::error::CoreError;
|
||||
use litellm_core::ocr::transformation::OcrResponseHandling;
|
||||
use litellm_core::CoreResult;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::client::http_client;
|
||||
use super::common_utils::{poll_document_intelligence, truncate_error_body};
|
||||
use super::types::ProviderOcrRequest;
|
||||
use crate::client::http_client;
|
||||
|
||||
pub(crate) async fn execute_ocr_provider_call(request: ProviderOcrRequest) -> CoreResult<Value> {
|
||||
let mut request_builder = http_client().post(&request.url).json(&request.body);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming};
|
||||
use litellm_core::error::CoreError;
|
||||
use litellm_core::ocr::transformation::OcrAuthStrategy;
|
||||
use litellm_core::CoreResult;
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::common_utils::{
|
||||
convert_document_url_to_data_uri, has_header, ocr_provider_config, string_headers,
|
||||
|
|
@ -292,7 +292,7 @@ fn parse_ocr_pre_call_guardrail_request(
|
|||
Some(_) => {
|
||||
return Err(CoreError::InvalidRequest(
|
||||
"OCR pre_call guardrail optional_params must be an object".to_string(),
|
||||
))
|
||||
));
|
||||
}
|
||||
None => Map::new(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
use litellm_core::call_lifecycle::CallLifecycle;
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::call_lifecycle::CallLifecycle;
|
||||
use serde_json::Value;
|
||||
|
||||
mod client;
|
||||
mod common_utils;
|
||||
mod handler;
|
||||
mod hooks;
|
||||
|
|
@ -12,7 +11,7 @@ mod types;
|
|||
pub use types::OcrRequest;
|
||||
|
||||
use handler::execute_ocr_provider_call;
|
||||
use prepare::{prepare_ocr_call, PreparedOcrCall};
|
||||
use prepare::{PreparedOcrCall, prepare_ocr_call};
|
||||
|
||||
pub async fn ocr(request: OcrRequest<'_>) -> CoreResult<Value> {
|
||||
let PreparedOcrCall { request, hooks } = prepare_ocr_call(request);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use litellm_core::routing_utils::provider::{get_custom_llm_provider, CustomLlmProvider};
|
||||
use litellm_core::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider};
|
||||
|
||||
use super::hooks::OcrLifecycleHooks;
|
||||
use super::types::{OcrRequest, PreparedOcrRequest};
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ use std::time::Duration;
|
|||
|
||||
use litellm_core::error::CoreError;
|
||||
use litellm_core::ocr::transformation::OcrResponseHandling;
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
use super::common_utils::{has_header, ocr_provider_config, string_headers, truncate_error_body};
|
||||
use super::{ocr, OcrRequest};
|
||||
use super::{OcrRequest, ocr};
|
||||
use crate::integrations::custom_guardrail::{
|
||||
CustomGuardrail, GuardrailContext, GuardrailDecision, GuardrailError, GuardrailEventHook,
|
||||
GuardrailFuture, GuardrailRequest,
|
||||
|
|
@ -228,19 +228,23 @@ fn truncate_error_body_does_not_split_multibyte_chars() {
|
|||
#[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!(
|
||||
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("vertex_ai", "deepseek-ocr-maas")
|
||||
.expect("vertex deepseek config resolves")
|
||||
.supported_ocr_params()
|
||||
.contains(&"temperature")
|
||||
);
|
||||
assert!(ocr_provider_config("openai", "gpt-4o").is_none());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
//!
|
||||
//! Compiled only under the `python-config` feature.
|
||||
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::error::CoreError;
|
||||
use litellm_core::router::{Deployment, Router};
|
||||
use litellm_core::CoreResult;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use crate::gil;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! Health probes. Simple-route template: a `router()` plus its handlers, in one file.
|
||||
|
||||
use axum::Router;
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
mod service;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Json, State};
|
||||
use axum::http::header::{HeaderMap, HeaderValue, CACHE_CONTROL, CONTENT_TYPE};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
use litellm_core::CoreError;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
|
|
@ -125,9 +125,9 @@ mod tests {
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
|
||||
use axum::http::Request;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
|
||||
use litellm_core::router::{Deployment, LiteLLMParams, Router as ModelRouter};
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
|
@ -439,8 +439,8 @@ mod tests {
|
|||
.await
|
||||
.expect("response body reads");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&response_body).expect("error is json")
|
||||
["error"]["message"],
|
||||
serde_json::from_slice::<serde_json::Value>(&response_body).expect("error is json")["error"]
|
||||
["message"],
|
||||
"messages provider request failed"
|
||||
);
|
||||
server.await.expect("upstream task completes");
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use litellm_core::{CoreError, CoreResult};
|
|||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::constants::ANTHROPIC_MESSAGES_PROVIDER;
|
||||
use crate::messages::{execute_messages, MessagesRequest};
|
||||
use crate::messages::{MessagesRequest, execute_messages};
|
||||
|
||||
pub(crate) enum MessagesResponse {
|
||||
Json(Value),
|
||||
|
|
|
|||
|
|
@ -6,17 +6,17 @@
|
|||
|
||||
mod service;
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::io::realtime_pool::RealtimePool;
|
||||
use axum::Router;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use litellm_core::realtime::types::RealtimeEvent;
|
||||
use litellm_core::router::Router as ModelRouter;
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@
|
|||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::io::realtime_pool::{upstream_key, RealtimePool};
|
||||
use crate::io::realtime_pool::{RealtimePool, upstream_key};
|
||||
use futures_util::{Sink, Stream};
|
||||
use litellm_core::CoreResult;
|
||||
use litellm_core::error::CoreError;
|
||||
use litellm_core::realtime::types::RealtimeEvent;
|
||||
use litellm_core::router::Router;
|
||||
use litellm_core::CoreResult;
|
||||
|
||||
/// Select a deployment for `model` and splice the client stream to the provider.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
mod service;
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use futures_util::{Sink, SinkExt, StreamExt};
|
||||
use litellm_core::responses::types::{ResponsesErrorFrame, ResponsesWsEvent, ResponsesWsEventType};
|
||||
use litellm_core::router::Router as ModelRouter;
|
||||
|
|
|
|||
2
litellm-rust/crates/core/src/audio_transcription/mod.rs
Normal file
2
litellm-rust/crates/core/src/audio_transcription/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod transformation;
|
||||
pub mod types;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::CoreResult;
|
||||
|
||||
use super::types::{AudioTranscriptionRequestData, AudioTranscriptionResponseData};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AudioTranscriptionAuth {
|
||||
Bearer,
|
||||
AwsSigV4 {
|
||||
region: String,
|
||||
service: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
pub trait AudioTranscriptionProviderConfig: Sync {
|
||||
fn supported_transcription_params(&self) -> &'static [&'static str];
|
||||
|
||||
fn map_transcription_params(&self, params: &Map<String, Value>) -> Map<String, Value> {
|
||||
params
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
self.supported_transcription_params()
|
||||
.contains(&key.as_str())
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn transform_transcription_request(
|
||||
&self,
|
||||
model: &str,
|
||||
audio: Value,
|
||||
optional_params: Map<String, Value>,
|
||||
) -> CoreResult<AudioTranscriptionRequestData>;
|
||||
|
||||
fn transform_transcription_response(
|
||||
&self,
|
||||
model: &str,
|
||||
response_json: Value,
|
||||
) -> CoreResult<AudioTranscriptionResponseData>;
|
||||
|
||||
fn complete_url(
|
||||
&self,
|
||||
api_base: Option<&str>,
|
||||
model: &str,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> CoreResult<String>;
|
||||
|
||||
fn auth_strategy(
|
||||
&self,
|
||||
model: &str,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> CoreResult<AudioTranscriptionAuth>;
|
||||
}
|
||||
20
litellm-rust/crates/core/src/audio_transcription/types.rs
Normal file
20
litellm-rust/crates/core/src/audio_transcription/types.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AudioTranscriptionRequestData {
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AudioTranscriptionResponseData {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl AudioTranscriptionResponseData {
|
||||
pub fn into_json(self) -> Value {
|
||||
serde_json::json!({
|
||||
"text": self.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -134,8 +134,8 @@ impl<V: Clone> InMemoryCache<V> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use super::InMemoryCache;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod audio_transcription;
|
||||
pub mod caching;
|
||||
pub mod call_lifecycle;
|
||||
pub mod constants;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ pub trait AnthropicMessagesProviderConfig: Sync {
|
|||
MessagesAuthStrategy::Header("x-api-key")
|
||||
}
|
||||
|
||||
fn accepts_bearer_auth(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_headers(&self) -> &'static [(&'static str, &'static str)] {
|
||||
&[
|
||||
("anthropic-version", "2023-06-01"),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use crate::messages::types::{
|
|||
MessageContent, SystemPrompt,
|
||||
};
|
||||
use crate::providers::anthropic::messages::transformation::{
|
||||
non_empty, AnthropicMessagesConfig, ANTHROPIC_MESSAGES_CONFIG,
|
||||
ANTHROPIC_MESSAGES_CONFIG, AnthropicMessagesConfig, non_empty,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
|
|
@ -163,6 +163,10 @@ impl AnthropicMessagesProviderConfig for AzureAnthropicMessagesConfig {
|
|||
self.anthropic.auth_strategy()
|
||||
}
|
||||
|
||||
fn accepts_bearer_auth(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_headers(&self) -> &'static [(&'static str, &'static str)] {
|
||||
self.anthropic.default_headers()
|
||||
}
|
||||
|
|
@ -294,6 +298,11 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_bearer_auth_for_entra_id() {
|
||||
assert!(AZURE_ANTHROPIC_MESSAGES_CONFIG.accepts_bearer_auth());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_headers_match_python() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::error::{json_type_name, CoreError, CoreResult};
|
||||
use crate::error::{CoreError, CoreResult, json_type_name};
|
||||
use crate::ocr::transformation::{OcrAuthStrategy, OcrProviderConfig, OcrResponseHandling};
|
||||
use crate::ocr::types::{OcrRequestData, OcrResponseData};
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::providers::mistral::ocr::transformation::MISTRAL_OCR_CONFIG;
|
||||
|
||||
|
|
@ -206,11 +206,11 @@ pub fn complete_document_intelligence_url(
|
|||
AZURE_DOCUMENT_INTELLIGENCE_API_VERSION
|
||||
);
|
||||
|
||||
if let Some(pages) = optional_params.get("pages") {
|
||||
if let Some(normalized) = normalize_pages_param(pages)? {
|
||||
url.push_str("&pages=");
|
||||
url.push_str(&normalized);
|
||||
}
|
||||
if let Some(pages) = optional_params.get("pages")
|
||||
&& let Some(normalized) = normalize_pages_param(pages)?
|
||||
{
|
||||
url.push_str("&pages=");
|
||||
url.push_str(&normalized);
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
|
|
@ -231,7 +231,7 @@ fn document_url_from_mistral_document(document: &Value) -> CoreResult<&str> {
|
|||
other => {
|
||||
return Err(CoreError::InvalidRequest(format!(
|
||||
"Invalid document type: {other}. Must be 'document_url' or 'image_url'"
|
||||
)))
|
||||
)));
|
||||
}
|
||||
};
|
||||
object
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::audio_transcription::transformation::{
|
||||
AudioTranscriptionAuth, AudioTranscriptionProviderConfig,
|
||||
};
|
||||
use crate::audio_transcription::types::{
|
||||
AudioTranscriptionRequestData, AudioTranscriptionResponseData,
|
||||
};
|
||||
use crate::error::{CoreError, CoreResult, json_type_name};
|
||||
|
||||
use super::aws_base::AwsAuthConfig;
|
||||
use super::constants::{
|
||||
AWS_REGION, AWS_REGION_NAME, BEDROCK_RUNTIME_ENDPOINT_TEMPLATE, BEDROCK_SERVICE,
|
||||
DEFAULT_BEDROCK_REGION,
|
||||
};
|
||||
|
||||
const SUPPORTED_PARAMS: &[&str] = &["language", "prompt", "temperature", "response_format"];
|
||||
|
||||
pub static BEDROCK_AUDIO_TRANSCRIPTION_CONFIG: BedrockAudioTranscriptionConfig =
|
||||
BedrockAudioTranscriptionConfig;
|
||||
|
||||
pub struct BedrockAudioTranscriptionConfig;
|
||||
|
||||
pub fn bedrock_model_id_and_region(model: &str) -> (String, Option<String>) {
|
||||
let mut stripped = model;
|
||||
for prefix in ["bedrock/converse/", "bedrock/", "converse/"] {
|
||||
if let Some(value) = stripped.strip_prefix(prefix) {
|
||||
stripped = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut region = None;
|
||||
if let Some((candidate, remainder)) = stripped.split_once('/')
|
||||
&& is_bedrock_region(candidate)
|
||||
{
|
||||
region = Some(candidate.to_string());
|
||||
stripped = remainder;
|
||||
}
|
||||
for prefix in ["nova-2/", "nova/"] {
|
||||
if let Some(value) = stripped.strip_prefix(prefix) {
|
||||
stripped = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if region.is_none() {
|
||||
region = stripped
|
||||
.strip_prefix("arn:")
|
||||
.and_then(|value| value.split(':').nth(3))
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
}
|
||||
(stripped.to_string(), region)
|
||||
}
|
||||
|
||||
fn is_bedrock_region(value: &str) -> bool {
|
||||
value.len() > 3
|
||||
&& value.contains('-')
|
||||
&& value
|
||||
.chars()
|
||||
.all(|char| char.is_ascii_alphanumeric() || char == '-')
|
||||
}
|
||||
|
||||
pub fn resolve_bedrock_region(
|
||||
model_region: Option<&str>,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> String {
|
||||
if let Some(region) = optional_params
|
||||
.get("aws_region_name")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return region.to_string();
|
||||
}
|
||||
if let Some(region) = model_region {
|
||||
return region.to_string();
|
||||
}
|
||||
env_lookup(AWS_REGION_NAME)
|
||||
.or_else(|| env_lookup(AWS_REGION))
|
||||
.unwrap_or_else(|| DEFAULT_BEDROCK_REGION.to_string())
|
||||
}
|
||||
|
||||
fn audio_fields(audio: Value) -> CoreResult<(String, String)> {
|
||||
let object = audio.as_object().ok_or_else(|| CoreError::InvalidType {
|
||||
expected: "object",
|
||||
actual: json_type_name(&audio),
|
||||
})?;
|
||||
let data = object
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(CoreError::MissingField("audio.data"))?;
|
||||
let format = object
|
||||
.get("format")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| matches!(*value, "wav" | "mp3" | "flac" | "ogg"))
|
||||
.ok_or_else(|| {
|
||||
CoreError::InvalidRequest("audio.format must be wav, mp3, flac, or ogg".to_string())
|
||||
})?;
|
||||
Ok((data.to_string(), format.to_string()))
|
||||
}
|
||||
|
||||
fn optional_string<'a>(params: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
impl AudioTranscriptionProviderConfig for BedrockAudioTranscriptionConfig {
|
||||
fn supported_transcription_params(&self) -> &'static [&'static str] {
|
||||
SUPPORTED_PARAMS
|
||||
}
|
||||
|
||||
fn transform_transcription_request(
|
||||
&self,
|
||||
_model: &str,
|
||||
audio: Value,
|
||||
optional_params: Map<String, Value>,
|
||||
) -> CoreResult<AudioTranscriptionRequestData> {
|
||||
let (data, format) = audio_fields(audio)?;
|
||||
let mut instruction = "Transcribe the audio. Respond with only the transcript.".to_string();
|
||||
if let Some(language) = optional_string(&optional_params, "language") {
|
||||
instruction.push_str(&format!(" The audio language is {language}."));
|
||||
}
|
||||
if let Some(prompt) = optional_string(&optional_params, "prompt") {
|
||||
instruction.push_str(&format!(" Additional context: {prompt}"));
|
||||
}
|
||||
let mut inference_config = Map::from_iter([("maxTokens".to_string(), json!(4096))]);
|
||||
if let Some(temperature) = optional_params.get("temperature") {
|
||||
inference_config.insert("temperature".to_string(), temperature.clone());
|
||||
}
|
||||
Ok(AudioTranscriptionRequestData {
|
||||
body: json!({
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"audio": {"format": format, "source": {"bytes": data}}},
|
||||
{"text": instruction}
|
||||
]
|
||||
}],
|
||||
"system": [{"text": "You are a transcription assistant."}],
|
||||
"inferenceConfig": inference_config,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn transform_transcription_response(
|
||||
&self,
|
||||
_model: &str,
|
||||
response_json: Value,
|
||||
) -> CoreResult<AudioTranscriptionResponseData> {
|
||||
let content = response_json
|
||||
.get("output")
|
||||
.and_then(|value| value.get("message"))
|
||||
.and_then(|value| value.get("content"))
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
CoreError::InvalidResponse("Bedrock response has no output content".to_string())
|
||||
})?;
|
||||
let mut text = String::new();
|
||||
for block in content {
|
||||
if let Some(value) = block.get("text").and_then(Value::as_str) {
|
||||
text.push_str(value);
|
||||
}
|
||||
}
|
||||
Ok(AudioTranscriptionResponseData { text })
|
||||
}
|
||||
|
||||
fn complete_url(
|
||||
&self,
|
||||
api_base: Option<&str>,
|
||||
model: &str,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> CoreResult<String> {
|
||||
let (model_id, model_region) = bedrock_model_id_and_region(model);
|
||||
let region = resolve_bedrock_region(model_region.as_deref(), optional_params, env_lookup);
|
||||
let endpoint = optional_params
|
||||
.get("aws_bedrock_runtime_endpoint")
|
||||
.and_then(Value::as_str)
|
||||
.or(api_base)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| BEDROCK_RUNTIME_ENDPOINT_TEMPLATE.replace("{region}", ®ion));
|
||||
Ok(format!(
|
||||
"{}/model/{model_id}/converse",
|
||||
endpoint.trim_end_matches('/')
|
||||
))
|
||||
}
|
||||
|
||||
fn auth_strategy(
|
||||
&self,
|
||||
model: &str,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> CoreResult<AudioTranscriptionAuth> {
|
||||
let (_, model_region) = bedrock_model_id_and_region(model);
|
||||
Ok(AudioTranscriptionAuth::AwsSigV4 {
|
||||
region: resolve_bedrock_region(model_region.as_deref(), optional_params, env_lookup),
|
||||
service: BEDROCK_SERVICE,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn aws_auth_config(
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> AwsAuthConfig {
|
||||
let value = |key: &str| {
|
||||
optional_params
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
let env = |key: &str| env_lookup(key);
|
||||
AwsAuthConfig {
|
||||
access_key_id: value("aws_access_key_id").or_else(|| env("AWS_ACCESS_KEY_ID")),
|
||||
secret_access_key: value("aws_secret_access_key").or_else(|| env("AWS_SECRET_ACCESS_KEY")),
|
||||
session_token: value("aws_session_token").or_else(|| env("AWS_SESSION_TOKEN")),
|
||||
region_name: value("aws_region_name").or_else(|| env(AWS_REGION_NAME)),
|
||||
session_name: value("aws_session_name").or_else(|| env("AWS_SESSION_NAME")),
|
||||
profile_name: value("aws_profile_name").or_else(|| env("AWS_PROFILE_NAME")),
|
||||
role_name: value("aws_role_name").or_else(|| env("AWS_ROLE_NAME")),
|
||||
web_identity_token: value("aws_web_identity_token")
|
||||
.or_else(|| env("AWS_WEB_IDENTITY_TOKEN")),
|
||||
sts_endpoint: value("aws_sts_endpoint").or_else(|| env("AWS_STS_ENDPOINT")),
|
||||
external_id: value("aws_external_id").or_else(|| env("AWS_EXTERNAL_ID")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn no_env(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_matches_python_shape() {
|
||||
let params = Map::from_iter([
|
||||
("language".to_string(), json!("en")),
|
||||
("prompt".to_string(), json!("Speaker names")),
|
||||
("temperature".to_string(), json!(0)),
|
||||
("timestamp_granularities".to_string(), json!(["word"])),
|
||||
]);
|
||||
let params = BEDROCK_AUDIO_TRANSCRIPTION_CONFIG.map_transcription_params(¶ms);
|
||||
let result = BEDROCK_AUDIO_TRANSCRIPTION_CONFIG
|
||||
.transform_transcription_request(
|
||||
"mistral.voxtral-mini-3b-2507",
|
||||
json!({"data": "AQI=", "format": "wav", "filename": "sample.wav"}),
|
||||
params,
|
||||
)
|
||||
.expect("request");
|
||||
assert_eq!(
|
||||
result.body,
|
||||
json!({
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"audio": {"format": "wav", "source": {"bytes": "AQI="}}},
|
||||
{"text": "Transcribe the audio. Respond with only the transcript. The audio language is en. Additional context: Speaker names"}
|
||||
]
|
||||
}],
|
||||
"system": [{"text": "You are a transcription assistant."}],
|
||||
"inferenceConfig": {"maxTokens": 4096, "temperature": 0}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_concatenates_content_blocks() {
|
||||
let result = BEDROCK_AUDIO_TRANSCRIPTION_CONFIG
|
||||
.transform_transcription_response(
|
||||
"model",
|
||||
json!({"output": {"message": {"content": [{"text": "hello "}, {"text": "world"}]}}}),
|
||||
)
|
||||
.expect("response");
|
||||
assert_eq!(result.text, "hello world");
|
||||
assert_eq!(result.into_json(), json!({"text": "hello world"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_audio_is_rejected() {
|
||||
let result = BEDROCK_AUDIO_TRANSCRIPTION_CONFIG.transform_transcription_request(
|
||||
"model",
|
||||
json!({"data": "AQI="}),
|
||||
Map::new(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_and_url_precedence_match_python() {
|
||||
let params = Map::from_iter([("aws_region_name".to_string(), json!("eu-west-1"))]);
|
||||
let url = BEDROCK_AUDIO_TRANSCRIPTION_CONFIG
|
||||
.complete_url(
|
||||
None,
|
||||
"bedrock/us-east-1/mistral.voxtral-mini-3b-2507",
|
||||
¶ms,
|
||||
&no_env,
|
||||
)
|
||||
.expect("url");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://bedrock-runtime.eu-west-1.amazonaws.com/model/mistral.voxtral-mini-3b-2507/converse"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,10 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||
|
||||
use crate::caching::in_memory_cache::InMemoryCache;
|
||||
use crate::error::{CoreError, CoreResult};
|
||||
use aws_credential_types::provider::ProvideCredentials;
|
||||
use aws_credential_types::Credentials;
|
||||
use aws_credential_types::provider::ProvideCredentials;
|
||||
use aws_sigv4::http_request::{
|
||||
sign, SignableBody, SignableRequest, SigningParams, SigningSettings,
|
||||
SignableBody, SignableRequest, SigningParams, SigningSettings, sign,
|
||||
};
|
||||
use aws_sigv4::sign::v4;
|
||||
use aws_smithy_runtime_api::client::identity::Identity;
|
||||
|
|
@ -52,7 +52,7 @@ pub struct AwsAuthConfig {
|
|||
}
|
||||
|
||||
impl AwsAuthConfig {
|
||||
fn with_environment(self, env_lookup: &dyn Fn(&str) -> Option<String>) -> Self {
|
||||
fn with_environment(self, env_lookup: &(dyn Fn(&str) -> Option<String> + Sync)) -> Self {
|
||||
Self {
|
||||
access_key_id: self.access_key_id.or_else(|| env_lookup(AWS_ACCESS_KEY_ID)),
|
||||
secret_access_key: self
|
||||
|
|
@ -144,7 +144,7 @@ fn same_role_arns(target: &str, caller: &str) -> bool {
|
|||
|
||||
pub fn classify_auth(
|
||||
config: AwsAuthConfig,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
env_lookup: &(dyn Fn(&str) -> Option<String> + Sync),
|
||||
) -> AwsAuthFlow {
|
||||
let config = config.with_environment(env_lookup);
|
||||
if let (Some(token), Some(role), Some(session_name)) = (
|
||||
|
|
@ -194,7 +194,7 @@ pub fn classify_auth(
|
|||
|
||||
pub async fn resolve_credentials(
|
||||
config: AwsAuthConfig,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
env_lookup: &(dyn Fn(&str) -> Option<String> + Sync),
|
||||
) -> CoreResult<Credentials> {
|
||||
let resolved = config.clone().with_environment(env_lookup);
|
||||
let flow = classify_auth(config, env_lookup);
|
||||
|
|
@ -368,11 +368,11 @@ async fn is_already_running_as_role(role: &str, config: &AwsAuthConfig) -> CoreR
|
|||
if let (Ok(current_role), Ok(token_file)) = (
|
||||
std::env::var(AWS_ROLE_ARN),
|
||||
std::env::var(AWS_WEB_IDENTITY_TOKEN_FILE),
|
||||
) {
|
||||
if !token_file.is_empty() {
|
||||
return Ok(same_role_arns(role, ¤t_role));
|
||||
}
|
||||
) && !token_file.is_empty()
|
||||
{
|
||||
return Ok(same_role_arns(role, ¤t_role));
|
||||
}
|
||||
|
||||
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
|
||||
if let Some(region) = config.region_name.clone() {
|
||||
loader = loader.region(aws_types::region::Region::new(region));
|
||||
|
|
@ -639,7 +639,9 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
signed.get("Authorization").map(String::as_str),
|
||||
Some("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20240102/us-east-1/bedrock/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-security-token, Signature=55c027ef47527d3ad63f1735f9d099efdbc99f296ff914bd94e727e24ec0e464")
|
||||
Some(
|
||||
"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20240102/us-east-1/bedrock/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-security-token, Signature=55c027ef47527d3ad63f1735f9d099efdbc99f296ff914bd94e727e24ec0e464"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ pub const AWS_ACCESS_KEY_ID: &str = "AWS_ACCESS_KEY_ID";
|
|||
pub const AWS_SECRET_ACCESS_KEY: &str = "AWS_SECRET_ACCESS_KEY";
|
||||
pub const AWS_SESSION_TOKEN: &str = "AWS_SESSION_TOKEN";
|
||||
pub const AWS_REGION_NAME: &str = "AWS_REGION_NAME";
|
||||
pub const AWS_REGION: &str = "AWS_REGION";
|
||||
pub const AWS_SESSION_NAME: &str = "AWS_SESSION_NAME";
|
||||
pub const AWS_PROFILE_NAME: &str = "AWS_PROFILE_NAME";
|
||||
pub const AWS_ROLE_NAME: &str = "AWS_ROLE_NAME";
|
||||
|
|
@ -12,3 +13,6 @@ pub const AWS_STS_ENDPOINT: &str = "AWS_STS_ENDPOINT";
|
|||
pub const AWS_EXTERNAL_ID: &str = "AWS_EXTERNAL_ID";
|
||||
pub const BEDROCK_SERVICE: &str = "bedrock";
|
||||
pub const DEFAULT_SESSION_NAME_PREFIX: &str = "litellm-session";
|
||||
pub const DEFAULT_BEDROCK_REGION: &str = "us-west-2";
|
||||
pub const BEDROCK_RUNTIME_ENDPOINT_TEMPLATE: &str =
|
||||
"https://bedrock-runtime.{region}.amazonaws.com";
|
||||
|
|
|
|||
|
|
@ -2,5 +2,7 @@
|
|||
//! with Python's `BaseAWSLLM`; the broader core purity guidance is reconciled
|
||||
//! separately.
|
||||
|
||||
#[cfg(feature = "bedrock-auth")]
|
||||
pub mod audio_transcription;
|
||||
pub mod aws_base;
|
||||
mod constants;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::error::{json_type_name, CoreError, CoreResult};
|
||||
use crate::error::{CoreError, CoreResult, json_type_name};
|
||||
use crate::ocr::transformation::OcrProviderConfig;
|
||||
use crate::ocr::types::{OcrRequestData, OcrResponseData};
|
||||
use serde_json::{Map, Value};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::CoreResult;
|
||||
use crate::realtime::transformation::RealtimeProviderConfig;
|
||||
use crate::realtime::types::{RealtimeEvent, RealtimeTransformResult};
|
||||
use crate::CoreResult;
|
||||
|
||||
/// Default OpenAI API base, used when the caller does not override `api_base`.
|
||||
pub const OPENAI_REALTIME_DEFAULT_API_BASE: &str = "https://api.openai.com";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::responses::types::{ResponsesWsEvent, ResponsesWsTransformResult};
|
||||
use crate::responses::websocket::{enforce_model, ResponsesWebSocketProviderConfig};
|
||||
use crate::CoreResult;
|
||||
use crate::responses::types::{ResponsesWsEvent, ResponsesWsTransformResult};
|
||||
use crate::responses::websocket::{ResponsesWebSocketProviderConfig, enforce_model};
|
||||
|
||||
pub struct OpenAIResponsesWsConfig;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::error::{json_type_name, CoreError, CoreResult};
|
||||
use crate::error::{CoreError, CoreResult, json_type_name};
|
||||
use crate::ocr::transformation::OcrProviderConfig;
|
||||
use crate::ocr::types::{OcrRequestData, OcrResponseData};
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::providers::mistral::ocr::transformation::MISTRAL_OCR_CONFIG;
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ fn document_content_item(document: &Value) -> CoreResult<Value> {
|
|||
other => {
|
||||
return Err(CoreError::InvalidRequest(format!(
|
||||
"Unsupported document type: {other}. Expected 'image_url' or 'document_url'"
|
||||
)))
|
||||
)));
|
||||
}
|
||||
};
|
||||
let url = object
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::realtime::types::{RealtimeEvent, RealtimeTransformResult};
|
||||
use crate::CoreResult;
|
||||
use crate::realtime::types::{RealtimeEvent, RealtimeTransformResult};
|
||||
|
||||
pub trait RealtimeProviderConfig {
|
||||
/// Build the upstream WebSocket URL (e.g. `wss://api.openai.com/v1/realtime?model=…`).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::CoreResult;
|
||||
use crate::constants::{OPENAI_RESPONSES_DEFAULT_API_BASE, OPENAI_RESPONSES_PATH};
|
||||
use crate::responses::types::{ResponsesWsEvent, ResponsesWsEventType, ResponsesWsTransformResult};
|
||||
use crate::CoreResult;
|
||||
|
||||
pub trait ResponsesWebSocketProviderConfig: Sync {
|
||||
fn supports_native_websocket(&self) -> bool {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,13 @@ Python-compatible dictionaries.
|
|||
- Provider dispatch belongs in Rust route modules such as
|
||||
`litellm_providers::ocr`, not in this PyO3 crate.
|
||||
- Python owns rollout state and fallback. Rust should return errors; Python
|
||||
decides whether to raise or fall back.
|
||||
decides whether to raise or fall back. For a rust-only provider/route (no
|
||||
Python reference), the Python side is a thin dispatch that calls Rust and
|
||||
raises when the bridge is unavailable, with no fallback.
|
||||
- Keep the Python interface minimal (well under 100 lines per route): it only
|
||||
marshals inputs and calls Rust. Do not add per-route feature flags, and do
|
||||
not put provider dispatch in `litellm/main.py`; it lives in a thin dispatch
|
||||
class under `litellm/llms/<provider>/<route>/`.
|
||||
|
||||
## Data Handling
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ name = "_native"
|
|||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
litellm-core.workspace = true
|
||||
litellm-core = { workspace = true, features = ["bedrock-auth"] }
|
||||
litellm-ai-gateway = { workspace = true, default-features = false }
|
||||
pyo3 = { workspace = true, features = ["extension-module"] }
|
||||
pyo3-async-runtimes.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use litellm_ai_gateway::io::messages::{messages as run_messages, MessagesRequest};
|
||||
use litellm_ai_gateway::io::ocr::{ocr as run_ocr, OcrRequest};
|
||||
use litellm_ai_gateway::io::audio_transcription::{
|
||||
AudioTranscriptionRequest, audio_transcription as run_audio_transcription,
|
||||
};
|
||||
use litellm_ai_gateway::io::messages::{MessagesRequest, messages as run_messages};
|
||||
use litellm_ai_gateway::io::ocr::{OcrRequest, ocr as run_ocr};
|
||||
use litellm_ai_gateway::io::responses_ws::ResponsesWebSocketConnection as RustResponsesWebSocketConnection;
|
||||
use litellm_core::error::CoreError;
|
||||
use pyo3::exceptions::{PyRuntimeError, PyValueError};
|
||||
|
|
@ -244,6 +247,93 @@ fn aocr(
|
|||
})
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn transcription(
|
||||
py: Python<'_>,
|
||||
model: String,
|
||||
audio: Py<PyAny>,
|
||||
api_key: Option<String>,
|
||||
api_base: Option<String>,
|
||||
custom_llm_provider: Option<String>,
|
||||
extra_headers: Option<Py<PyAny>>,
|
||||
optional_params: Option<Py<PyAny>>,
|
||||
timeout_seconds: Option<f64>,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
let audio = py_to_json(py, audio.bind(py))?;
|
||||
let extra_headers = match extra_headers {
|
||||
Some(headers) => Some(optional_object_to_map(py, "extra_headers", Some(headers))?),
|
||||
None => None,
|
||||
};
|
||||
let optional_params = optional_object_to_map(py, "optional_params", optional_params)?;
|
||||
let timeout = optional_timeout(timeout_seconds);
|
||||
let result = gil::release_gil(py, || {
|
||||
pyo3_async_runtimes::tokio::get_runtime().block_on(run_audio_transcription(
|
||||
AudioTranscriptionRequest {
|
||||
model: &model,
|
||||
audio,
|
||||
api_key: api_key.as_deref(),
|
||||
api_base: api_base.as_deref(),
|
||||
custom_llm_provider: custom_llm_provider.as_deref(),
|
||||
extra_headers,
|
||||
optional_params,
|
||||
timeout,
|
||||
callbacks: Vec::new(),
|
||||
guardrails: Vec::new(),
|
||||
request_metadata: Default::default(),
|
||||
litellm_call_id: None,
|
||||
},
|
||||
))
|
||||
});
|
||||
match result {
|
||||
Ok(value) => json_to_py(py, value),
|
||||
Err(err) => Err(core_error_to_pyerr(err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (model, audio, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, optional_params=None, timeout_seconds=None))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn atranscription(
|
||||
py: Python<'_>,
|
||||
model: String,
|
||||
audio: Py<PyAny>,
|
||||
api_key: Option<String>,
|
||||
api_base: Option<String>,
|
||||
custom_llm_provider: Option<String>,
|
||||
extra_headers: Option<Py<PyAny>>,
|
||||
optional_params: Option<Py<PyAny>>,
|
||||
timeout_seconds: Option<f64>,
|
||||
) -> PyResult<Bound<'_, PyAny>> {
|
||||
let audio = py_to_json(py, audio.bind(py))?;
|
||||
let extra_headers = match extra_headers {
|
||||
Some(headers) => Some(optional_object_to_map(py, "extra_headers", Some(headers))?),
|
||||
None => None,
|
||||
};
|
||||
let optional_params = optional_object_to_map(py, "optional_params", optional_params)?;
|
||||
let timeout = optional_timeout(timeout_seconds);
|
||||
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||
let value = run_audio_transcription(AudioTranscriptionRequest {
|
||||
model: &model,
|
||||
audio,
|
||||
api_key: api_key.as_deref(),
|
||||
api_base: api_base.as_deref(),
|
||||
custom_llm_provider: custom_llm_provider.as_deref(),
|
||||
extra_headers,
|
||||
optional_params,
|
||||
timeout,
|
||||
callbacks: Vec::new(),
|
||||
guardrails: Vec::new(),
|
||||
request_metadata: Default::default(),
|
||||
litellm_call_id: None,
|
||||
})
|
||||
.await
|
||||
.map_err(core_error_to_pyerr)?;
|
||||
Python::attach(|py| json_to_py(py, value))
|
||||
})
|
||||
}
|
||||
|
||||
type MarshaledMessagesInputs = (Value, Option<Map<String, Value>>, Option<Duration>);
|
||||
|
||||
fn marshal_messages_inputs(
|
||||
|
|
@ -341,6 +431,8 @@ fn gil_stats(py: Python<'_>) -> PyResult<Py<PyAny>> {
|
|||
fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
module.add_function(wrap_pyfunction!(ocr, module)?)?;
|
||||
module.add_function(wrap_pyfunction!(aocr, module)?)?;
|
||||
module.add_function(wrap_pyfunction!(transcription, module)?)?;
|
||||
module.add_function(wrap_pyfunction!(atranscription, module)?)?;
|
||||
module.add_function(wrap_pyfunction!(messages, module)?)?;
|
||||
module.add_function(wrap_pyfunction!(amessages, module)?)?;
|
||||
module.add_class::<ResponsesWebSocketConnection>()?;
|
||||
|
|
|
|||
|
|
@ -427,7 +427,9 @@ default_team_settings: Optional[List] = None
|
|||
max_user_budget: Optional[float] = None
|
||||
default_max_internal_user_budget: Optional[float] = None
|
||||
max_internal_user_budget: Optional[float] = None
|
||||
max_ui_session_budget: Optional[float] = 0.25 # $0.25 USD budgets for UI Chat sessions
|
||||
max_ui_session_budget: Optional[float] = (
|
||||
1.0 # USD budget for each dashboard login session (playground, test connection)
|
||||
)
|
||||
internal_user_budget_duration: Optional[str] = None
|
||||
tag_budget_config: Optional[Dict[str, "BudgetConfig"]] = None
|
||||
max_end_user_budget: Optional[float] = None
|
||||
|
|
|
|||
|
|
@ -58,12 +58,12 @@ class DiskCache(BaseCache):
|
|||
return return_val
|
||||
|
||||
def increment_cache(self, key, value: int, **kwargs) -> int:
|
||||
# get the value
|
||||
cached_value = self.get_cache(key=key)
|
||||
init_value = cached_value if isinstance(cached_value, int) else 0
|
||||
value = init_value + value
|
||||
self.set_cache(key, value, **kwargs)
|
||||
return value
|
||||
with self.disk_cache.transact():
|
||||
cached_value = self.get_cache(key=key)
|
||||
init_value = cached_value if isinstance(cached_value, int) else 0
|
||||
new_value = init_value + value
|
||||
self.set_cache(key, new_value, **kwargs)
|
||||
return new_value
|
||||
|
||||
async def async_get_cache(self, key, **kwargs):
|
||||
return self.get_cache(key=key, **kwargs)
|
||||
|
|
@ -76,12 +76,7 @@ class DiskCache(BaseCache):
|
|||
return return_val
|
||||
|
||||
async def async_increment(self, key, value: int, **kwargs) -> int:
|
||||
# get the value
|
||||
cached_value = await self.async_get_cache(key=key)
|
||||
init_value = cached_value if isinstance(cached_value, int) else 0
|
||||
value = init_value + value
|
||||
await self.async_set_cache(key, value, **kwargs)
|
||||
return value
|
||||
return self.increment_cache(key=key, value=value, **kwargs)
|
||||
|
||||
def flush_cache(self):
|
||||
self.disk_cache.clear()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import json
|
|||
import sys
|
||||
import time
|
||||
import heapq
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -46,6 +47,7 @@ class InMemoryCache(BaseCache):
|
|||
self.cache_dict: dict = {}
|
||||
self.ttl_dict: dict = {}
|
||||
self.expiration_heap: list[tuple[float, str]] = []
|
||||
self._increment_lock = threading.Lock()
|
||||
|
||||
def check_value_size(self, value: Any):
|
||||
"""
|
||||
|
|
@ -223,12 +225,13 @@ class InMemoryCache(BaseCache):
|
|||
return_val.append(val)
|
||||
return return_val
|
||||
|
||||
def increment_cache(self, key, value: int, **kwargs) -> int:
|
||||
# get the value
|
||||
init_value = self.get_cache(key=key) or 0
|
||||
value = init_value + value
|
||||
self.set_cache(key, value, **kwargs)
|
||||
return value
|
||||
def increment_cache(self, key, value: float, **kwargs) -> float:
|
||||
with self._increment_lock:
|
||||
# keep read-modify-write atomic
|
||||
init_value = self.get_cache(key=key) or 0
|
||||
value = init_value + value
|
||||
self.set_cache(key, value, **kwargs)
|
||||
return value
|
||||
|
||||
async def async_get_cache(self, key, **kwargs):
|
||||
return self.get_cache(key=key, **kwargs)
|
||||
|
|
@ -241,11 +244,7 @@ class InMemoryCache(BaseCache):
|
|||
return return_val
|
||||
|
||||
async def async_increment(self, key, value: float, **kwargs) -> float:
|
||||
# get the value
|
||||
init_value = await self.async_get_cache(key=key) or 0
|
||||
value = init_value + value
|
||||
await self.async_set_cache(key, value, **kwargs)
|
||||
return value
|
||||
return self.increment_cache(key=key, value=value, **kwargs)
|
||||
|
||||
async def async_increment_pipeline(
|
||||
self, increment_list: List["RedisPipelineIncrementOperation"], **kwargs
|
||||
|
|
|
|||
|
|
@ -264,6 +264,9 @@ MAX_REDIS_BUFFER_DEQUEUE_COUNT = int(os.getenv("MAX_REDIS_BUFFER_DEQUEUE_COUNT",
|
|||
# Bounds asyncio.Queue() instances (log queues, spend update queues, etc.) to prevent unbounded memory growth
|
||||
LITELLM_ASYNCIO_QUEUE_MAXSIZE = int(os.getenv("LITELLM_ASYNCIO_QUEUE_MAXSIZE", 1000))
|
||||
TOOL_POLICY_CACHE_TTL_SECONDS = int(os.getenv("TOOL_POLICY_CACHE_TTL_SECONDS", 60))
|
||||
GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS = int(
|
||||
os.getenv("GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS", 24 * 60 * 60)
|
||||
)
|
||||
# Aggregation threshold: default to 80% of the asyncio queue maxsize so the check can always trigger.
|
||||
# Must be < LITELLM_ASYNCIO_QUEUE_MAXSIZE; if set higher the aggregation logic will never fire.
|
||||
MAX_SIZE_IN_MEMORY_QUEUE = int(os.getenv("MAX_SIZE_IN_MEMORY_QUEUE", int(LITELLM_ASYNCIO_QUEUE_MAXSIZE * 0.8)))
|
||||
|
|
@ -1469,6 +1472,7 @@ _batch_polling_env = os.getenv("PROXY_BATCH_POLLING_ENABLED", "true").lower()
|
|||
PROXY_BATCH_POLLING_ENABLED = _batch_polling_env == "true"
|
||||
PROXY_BUDGET_RESCHEDULER_MAX_TIME = int(os.getenv("PROXY_BUDGET_RESCHEDULER_MAX_TIME", 605))
|
||||
PROXY_BATCH_WRITE_AT = int(os.getenv("PROXY_BATCH_WRITE_AT", 10)) # in seconds, increased from 10
|
||||
PROXY_CONFIG_RELOAD_INTERVAL_SECONDS = get_env_int("PROXY_CONFIG_RELOAD_INTERVAL_SECONDS", 30)
|
||||
|
||||
# APScheduler Configuration - MEMORY LEAK FIX
|
||||
# These settings prevent memory leaks in APScheduler's normalize() and _apply_jitter() functions
|
||||
|
|
@ -1524,6 +1528,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [
|
|||
# test_general_settings_ui_fields_are_db_overridable enforces that pairing.
|
||||
"enable_anthropic_prompt_caching",
|
||||
"anthropic_prompt_caching_ttl",
|
||||
"max_ui_session_budget",
|
||||
]
|
||||
SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"]
|
||||
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from openai.types.chat import ChatCompletionToolParam
|
|||
from openai.types.responses.function_tool_param import FunctionToolParam
|
||||
from openai.types.shared_params.function_definition import FunctionDefinition
|
||||
|
||||
from litellm.types.llms.anthropic import AnthropicMessagesTool
|
||||
from litellm.types.utils import ChatCompletionMessageToolCall
|
||||
|
||||
|
||||
|
|
@ -75,6 +76,20 @@ def transform_mcp_tool_to_openai_responses_api_tool(
|
|||
)
|
||||
|
||||
|
||||
def transform_mcp_tool_to_anthropic_tool(mcp_tool: MCPTool) -> AnthropicMessagesTool:
|
||||
"""Convert an MCP tool to an Anthropic Messages API tool."""
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
sanitize_input_schema_for_anthropic,
|
||||
)
|
||||
|
||||
return AnthropicMessagesTool(
|
||||
name=mcp_tool.name,
|
||||
description=mcp_tool.description or "",
|
||||
input_schema=sanitize_input_schema_for_anthropic(mcp_tool.inputSchema),
|
||||
type="custom",
|
||||
)
|
||||
|
||||
|
||||
async def load_mcp_tools(
|
||||
session: ClientSession, format: Literal["mcp", "openai"] = "mcp"
|
||||
) -> Union[List[MCPTool], List[ChatCompletionToolParam]]:
|
||||
|
|
|
|||
|
|
@ -91,7 +91,9 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
|
||||
# Pass through non-message injection points for provider-specific handling
|
||||
if remaining_points:
|
||||
non_default_params["cache_control_injection_points"] = remaining_points
|
||||
non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_as_judged(
|
||||
remaining_points
|
||||
)
|
||||
|
||||
return model, processed_messages, non_default_params
|
||||
|
||||
|
|
@ -310,6 +312,35 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
return ChatCompletionCachedContent(type="ephemeral", ttl=ttl)
|
||||
return ChatCompletionCachedContent(type="ephemeral")
|
||||
|
||||
@staticmethod
|
||||
def _stamped_as_judged(points: list[CacheControlInjectionPoint]) -> list[dict[str, object]]:
|
||||
"""Mark written-back points as having passed the client cache_control judgment.
|
||||
|
||||
Builds copies because config-owned point dicts are shared across
|
||||
requests; mutating them would leak the stamp into future requests.
|
||||
"""
|
||||
return [{**point, "_litellm_judged": True} for point in points]
|
||||
|
||||
@staticmethod
|
||||
def _should_stand_down(
|
||||
points: list[CacheControlInjectionPoint],
|
||||
messages: list[AllMessageValues],
|
||||
system: str | list | None,
|
||||
tools: list | None,
|
||||
) -> bool:
|
||||
"""Whether configured injection points must yield to client-set cache_control.
|
||||
|
||||
Points that a prior pass over this request already judged and wrote
|
||||
back carry the internal judged stamp; any re-entry (acompletion
|
||||
re-entering completion, the async-to-sync /v1/messages dispatch,
|
||||
interceptor sub-calls reusing the request kwargs) must not re-judge
|
||||
them, because by then the messages carry litellm's own injected marks
|
||||
and the judgment would misread those as client breakpoints.
|
||||
"""
|
||||
if all(point.get("_litellm_judged") for point in points):
|
||||
return False
|
||||
return AnthropicCacheControlHook._request_has_cache_control(messages, system, tools)
|
||||
|
||||
@staticmethod
|
||||
def _request_has_cache_control(
|
||||
messages: list[AllMessageValues],
|
||||
|
|
@ -322,7 +353,9 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
stand down entirely rather than add more, per the auto-caching contract.
|
||||
Tools count: they are a breakpoint the client can mark, they count toward
|
||||
the provider's four-block limit, and caching only the tool definitions is
|
||||
a common pattern, so injecting alongside them can exceed the cap.
|
||||
a common pattern, so injecting alongside them can exceed the cap. Tools
|
||||
carry the mark either at the top level (Anthropic shape) or nested under
|
||||
``function`` (OpenAI shape); the Anthropic chat transform accepts both.
|
||||
"""
|
||||
if any(AnthropicCacheControlHook._count_cache_control_blocks(msg) for msg in messages):
|
||||
return True
|
||||
|
|
@ -330,7 +363,14 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
if any(isinstance(block, dict) and block.get("cache_control") is not None for block in system):
|
||||
return True
|
||||
if tools is not None:
|
||||
return any(isinstance(tool, dict) and tool.get("cache_control") is not None for tool in tools)
|
||||
return any(
|
||||
isinstance(tool, dict)
|
||||
and (
|
||||
tool.get("cache_control") is not None
|
||||
or (isinstance(tool.get("function"), dict) and tool["function"].get("cache_control") is not None)
|
||||
)
|
||||
for tool in tools
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -392,13 +432,23 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
custom_llm_provider: str | None,
|
||||
tools: list | None = None,
|
||||
) -> None:
|
||||
"""For /chat/completions: add default injection points to the request params.
|
||||
"""For /chat/completions: resolve the injection points the request should carry.
|
||||
|
||||
No-op when injection points are already configured (explicit config wins).
|
||||
Seeding the param lets the existing prompt-management gate and the
|
||||
AnthropicCacheControlHook run unchanged.
|
||||
Configured injection points win over the automatic defaults, but stand
|
||||
down entirely when the client already marked its own cache_control
|
||||
breakpoints (messages or tools): injecting alongside them clashes with
|
||||
the client's caching strategy and can exceed the provider's four-block
|
||||
limit. The judgment happens once per request; points a prior pass
|
||||
wrote back carry the judged stamp and are never re-judged (see
|
||||
``_should_stand_down``). Seeding the param lets the existing
|
||||
prompt-management gate and the AnthropicCacheControlHook run
|
||||
unchanged.
|
||||
"""
|
||||
if non_default_params.get("cache_control_injection_points"):
|
||||
if AnthropicCacheControlHook._should_stand_down(
|
||||
non_default_params["cache_control_injection_points"], messages, None, tools
|
||||
):
|
||||
non_default_params.pop("cache_control_injection_points")
|
||||
return
|
||||
points = AnthropicCacheControlHook.get_default_injection_points(
|
||||
messages=messages,
|
||||
|
|
@ -421,18 +471,26 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
) -> Tuple[List[Dict], str | list | None]:
|
||||
"""Extract cache_control_injection_points from kwargs and apply if present.
|
||||
|
||||
When none are configured but ``litellm.enable_anthropic_prompt_caching``
|
||||
is on, synthesize default breakpoints for the native /v1/messages path.
|
||||
Pops the key from kwargs; if remaining (non-message) points exist they
|
||||
are written back so downstream transforms can handle them.
|
||||
Configured points stand down entirely when the client already marked
|
||||
its own cache_control breakpoints anywhere in the request. The
|
||||
judgment happens once per request; points a prior pass wrote back
|
||||
carry the judged stamp and are never re-judged (see
|
||||
``_should_stand_down``). When none are configured but
|
||||
``litellm.enable_anthropic_prompt_caching`` is on, synthesize default
|
||||
breakpoints for the native /v1/messages path. Pops the key from kwargs;
|
||||
if remaining (non-message) points exist they are written back so
|
||||
downstream transforms can handle them.
|
||||
"""
|
||||
typed_messages = cast(list[AllMessageValues], messages) # cast-ok: Anthropic-shaped dicts from v1/messages
|
||||
configured = cast( # cast-ok: kwargs is untyped; this key only holds the documented injection-point list
|
||||
list[CacheControlInjectionPoint] | None, kwargs.pop("cache_control_injection_points", None)
|
||||
)
|
||||
if configured and AnthropicCacheControlHook._should_stand_down(configured, typed_messages, system, tools):
|
||||
return messages, system
|
||||
injection_points: list[CacheControlInjectionPoint] = configured or []
|
||||
if not injection_points and model is not None:
|
||||
injection_points = AnthropicCacheControlHook.get_default_injection_points(
|
||||
messages=cast(list[AllMessageValues], messages), # cast-ok: Anthropic-shaped dicts from v1/messages
|
||||
messages=typed_messages,
|
||||
system=system,
|
||||
tools=tools,
|
||||
model=model,
|
||||
|
|
@ -447,7 +505,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
injection_points=injection_points,
|
||||
)
|
||||
if remaining:
|
||||
kwargs["cache_control_injection_points"] = remaining
|
||||
kwargs["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_as_judged(remaining)
|
||||
return messages, system
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
|
@ -46,7 +47,10 @@ if TYPE_CHECKING:
|
|||
dc = DualCache()
|
||||
|
||||
|
||||
from litellm.constants import PRE_CALL_EXECUTED_GUARDRAILS_KEY
|
||||
from litellm.constants import (
|
||||
GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS,
|
||||
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
|
||||
)
|
||||
from litellm.exceptions import (
|
||||
BlockedPiiEntityError,
|
||||
GuardrailRaisedException,
|
||||
|
|
@ -113,6 +117,7 @@ class CustomGuardrail(CustomLogger):
|
|||
on_sensitive_data: Optional[str] = None,
|
||||
sensitive_data_route_to_model: Optional[str] = None,
|
||||
sticky_session_routing: bool = True,
|
||||
only_scan_new_messages: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
|
|
@ -145,6 +150,7 @@ class CustomGuardrail(CustomLogger):
|
|||
self.on_sensitive_data: Optional[str] = on_sensitive_data
|
||||
self.sensitive_data_route_to_model: Optional[str] = sensitive_data_route_to_model
|
||||
self.sticky_session_routing: bool = sticky_session_routing
|
||||
self.only_scan_new_messages: bool = only_scan_new_messages
|
||||
|
||||
if supported_event_hooks:
|
||||
## validate event_hook is in supported_event_hooks
|
||||
|
|
@ -269,6 +275,100 @@ class CustomGuardrail(CustomLogger):
|
|||
"""Extract session_id from request data."""
|
||||
return get_session_id_from_request_data(request_data)
|
||||
|
||||
@staticmethod
|
||||
def _scanned_text_hash(text: str) -> str:
|
||||
"""Stable content hash for a single scannable text segment.
|
||||
|
||||
Hashing the exact text the provider would receive means an edited earlier
|
||||
segment produces a different hash and gets re-scanned, while an unchanged
|
||||
segment repeated on a later turn is skipped.
|
||||
"""
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
def _scanned_texts_cache_key(self, session_id: str) -> str:
|
||||
return f"guardrail_scanned_texts:{self.guardrail_name}:{session_id}"
|
||||
|
||||
async def filter_new_texts_for_session(
|
||||
self,
|
||||
texts: list[str] | None,
|
||||
request_data: dict[str, object],
|
||||
cache: DualCache,
|
||||
) -> list[str] | None:
|
||||
"""Return only the text segments not already scanned earlier in this session.
|
||||
|
||||
Returns ``None`` when incremental scanning is inactive (feature off, no
|
||||
session id, masking enabled, or the cache read failed). ``None`` signals
|
||||
the caller to fall back to a full scan; a returned list (possibly empty)
|
||||
signals the caller to scan only that subset and skip masking write-back.
|
||||
"""
|
||||
if not self.only_scan_new_messages or not texts:
|
||||
return None
|
||||
|
||||
if self.mask_request_content or self.mask_response_content:
|
||||
verbose_logger.warning(
|
||||
"Guardrail %s: only_scan_new_messages is not supported with masking; scanning full context.",
|
||||
self.guardrail_name,
|
||||
)
|
||||
return None
|
||||
|
||||
session_id = get_session_id_from_request_data(request_data)
|
||||
if not session_id:
|
||||
verbose_logger.debug(
|
||||
"Guardrail %s: only_scan_new_messages enabled but request has no session id; scanning full context.",
|
||||
self.guardrail_name,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
cached: object = await cache.async_get_cache(key=self._scanned_texts_cache_key(session_id))
|
||||
except Exception as e: # noqa: BLE001 # cache is best-effort; any failure must fall back to a full scan
|
||||
verbose_logger.warning(
|
||||
"Guardrail %s: failed to read scanned-message cache (%s); scanning full context.",
|
||||
self.guardrail_name,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
seen: set[str] = {str(h) for h in cached} if isinstance(cached, list) else set()
|
||||
return [text for text in texts if self._scanned_text_hash(text) not in seen]
|
||||
|
||||
async def mark_texts_scanned(
|
||||
self,
|
||||
texts: list[str] | None,
|
||||
request_data: dict[str, object],
|
||||
cache: DualCache,
|
||||
) -> None:
|
||||
"""Record the hashes of all text segments present on a successful (non-blocked) scan.
|
||||
|
||||
Called only after the guardrail allows the request, so a blocked segment is
|
||||
never marked scanned and will be re-checked if the client retries.
|
||||
"""
|
||||
if not self.only_scan_new_messages or not texts:
|
||||
return
|
||||
if self.mask_request_content or self.mask_response_content:
|
||||
return
|
||||
session_id = get_session_id_from_request_data(request_data)
|
||||
if not session_id:
|
||||
return
|
||||
|
||||
cache_key = self._scanned_texts_cache_key(session_id)
|
||||
current_hashes = [self._scanned_text_hash(text) for text in texts]
|
||||
try:
|
||||
existing: object = await cache.async_get_cache(key=cache_key)
|
||||
existing_hashes: list[str] = [str(h) for h in existing] if isinstance(existing, list) else []
|
||||
merged: list[str] = list(dict.fromkeys(existing_hashes + current_hashes))
|
||||
await cache.async_set_cache(
|
||||
key=cache_key,
|
||||
value=merged,
|
||||
ttl=GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # cache is best-effort; any failure must not block the request
|
||||
verbose_logger.warning(
|
||||
"Guardrail %s: failed to persist scanned-message cache (%s); next call will re-scan.",
|
||||
self.guardrail_name,
|
||||
e,
|
||||
)
|
||||
|
||||
def should_route_on_sensitive_data(self) -> bool:
|
||||
"""
|
||||
Returns True if this guardrail is configured to route requests
|
||||
|
|
|
|||
|
|
@ -7,11 +7,24 @@ duration_in_seconds is used in diff parts of the code base, example
|
|||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone, tzinfo
|
||||
from typing import Optional, Tuple
|
||||
import time as time_module
|
||||
from datetime import datetime, time, timedelta, timezone, tzinfo
|
||||
from typing import Final, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
_BUDGET_DURATION_WORD_ALIASES: Final[dict[str, str]] = {
|
||||
"hourly": "1h",
|
||||
"daily": "24h",
|
||||
"weekly": "7d",
|
||||
"monthly": "30d",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_duration(duration: str) -> str:
|
||||
return _BUDGET_DURATION_WORD_ALIASES.get(duration.strip().lower(), duration)
|
||||
|
||||
|
||||
def _extract_from_regex(duration: str) -> Tuple[int, str]:
|
||||
match = re.match(r"(\d+)(mo|[smhdw]?)", duration)
|
||||
|
|
@ -48,7 +61,7 @@ def duration_in_seconds(duration: str) -> int:
|
|||
|
||||
Returns time in seconds till when budget needs to be reset
|
||||
"""
|
||||
value, unit = _extract_from_regex(duration=duration)
|
||||
value, unit = _extract_from_regex(duration=_normalize_duration(duration))
|
||||
|
||||
if unit == "s":
|
||||
return value
|
||||
|
|
@ -61,7 +74,7 @@ def duration_in_seconds(duration: str) -> int:
|
|||
elif unit == "w":
|
||||
return value * 604800
|
||||
elif unit == "mo":
|
||||
now = time.time()
|
||||
now = time_module.time()
|
||||
current_time = datetime.fromtimestamp(now)
|
||||
|
||||
# Calculate target month and year, handling overflow past December
|
||||
|
|
@ -94,12 +107,17 @@ def duration_in_seconds(duration: str) -> int:
|
|||
raise ValueError(f"Unsupported duration unit, passed duration: {duration}")
|
||||
|
||||
|
||||
def get_next_standardized_reset_time(duration: str, current_time: datetime, timezone_str: str = "UTC") -> datetime:
|
||||
def get_next_standardized_reset_time(
|
||||
duration: str,
|
||||
current_time: datetime,
|
||||
timezone_str: str = "UTC",
|
||||
reset_time_of_day: time = time(0, 0),
|
||||
) -> datetime:
|
||||
"""
|
||||
Get the next standardized reset time based on the duration.
|
||||
|
||||
All durations will reset at predictable intervals, aligned from the current time:
|
||||
- Nd: If N=1, reset at next midnight; if N>1, reset every N days from now
|
||||
- Nd: If N=1, reset at the next `reset_time_of_day`; if N>1, reset every N days from now
|
||||
- Nh: Every N hours, aligned to hour boundaries (e.g., 1:00, 2:00)
|
||||
- Nm: Every N minutes, aligned to minute boundaries (e.g., 1:05, 1:10)
|
||||
- Ns: Every N seconds, aligned to second boundaries
|
||||
|
|
@ -108,17 +126,24 @@ def get_next_standardized_reset_time(duration: str, current_time: datetime, time
|
|||
- duration: Duration string (e.g. "30s", "30m", "30h", "30d")
|
||||
- current_time: Current datetime
|
||||
- timezone_str: Timezone string (e.g. "UTC", "US/Eastern", "Asia/Kolkata")
|
||||
- reset_time_of_day: Wall-clock time the reset lands on for day/week/month
|
||||
durations (defaults to midnight). Ignored for sub-day durations, where a
|
||||
time-of-day is meaningless.
|
||||
|
||||
Returns:
|
||||
- Next reset time at a standardized interval in the specified timezone
|
||||
"""
|
||||
# Set up timezone and normalize current time
|
||||
current_time, tz = _setup_timezone(current_time, timezone_str)
|
||||
current_time, _ = _setup_timezone(current_time, timezone_str)
|
||||
|
||||
# Parse duration
|
||||
value, unit = _parse_duration(duration)
|
||||
value, unit = _parse_duration(_normalize_duration(duration))
|
||||
if value is None:
|
||||
# Fall back to default if format is invalid
|
||||
verbose_logger.warning(
|
||||
"Unrecognized budget_duration %r; falling back to a next-midnight reset. "
|
||||
"Use the <int><unit> format (e.g. '1h', '7d', '30d', '1mo').",
|
||||
duration,
|
||||
)
|
||||
return current_time.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
|
||||
# Midnight of the current day in the specified timezone
|
||||
|
|
@ -126,9 +151,9 @@ def get_next_standardized_reset_time(duration: str, current_time: datetime, time
|
|||
|
||||
# Handle different time units
|
||||
if unit == "d":
|
||||
return _handle_day_reset(current_time, base_midnight, value, tz)
|
||||
return _handle_day_reset(current_time, base_midnight, value, reset_time_of_day)
|
||||
elif unit == "w":
|
||||
return _handle_day_reset(current_time, base_midnight, value * 7, tz)
|
||||
return _handle_day_reset(current_time, base_midnight, value * 7, reset_time_of_day)
|
||||
elif unit == "h":
|
||||
return _handle_hour_reset(current_time, base_midnight, value)
|
||||
elif unit == "m":
|
||||
|
|
@ -136,7 +161,7 @@ def get_next_standardized_reset_time(duration: str, current_time: datetime, time
|
|||
elif unit == "s":
|
||||
return _handle_second_reset(current_time, base_midnight, value)
|
||||
elif unit == "mo":
|
||||
return _handle_month_reset(current_time, base_midnight, value)
|
||||
return _handle_month_reset(current_time, base_midnight, value, reset_time_of_day)
|
||||
else:
|
||||
# Unrecognized unit, default to next midnight
|
||||
return base_midnight + timedelta(days=1)
|
||||
|
|
@ -175,46 +200,58 @@ def _parse_duration(duration: str) -> Tuple[Optional[int], Optional[str]]:
|
|||
return int(value), unit
|
||||
|
||||
|
||||
def _handle_day_reset(current_time: datetime, base_midnight: datetime, value: int, tz: tzinfo) -> datetime:
|
||||
def _apply_time_of_day(dt: datetime, reset_time_of_day: time) -> datetime:
|
||||
"""Set the wall-clock time of `dt` to `reset_time_of_day`, keeping its date and tzinfo."""
|
||||
return dt.replace(
|
||||
hour=reset_time_of_day.hour,
|
||||
minute=reset_time_of_day.minute,
|
||||
second=reset_time_of_day.second,
|
||||
microsecond=reset_time_of_day.microsecond,
|
||||
)
|
||||
|
||||
|
||||
def _next_occurrence(
|
||||
boundary_midnight: datetime,
|
||||
reset_time_of_day: time,
|
||||
current_time: datetime,
|
||||
period: timedelta,
|
||||
) -> datetime:
|
||||
"""Place the reset at `reset_time_of_day` on the boundary day, rolling forward one
|
||||
`period` if that instant has already passed (or is exactly now)."""
|
||||
candidate = _apply_time_of_day(boundary_midnight, reset_time_of_day)
|
||||
if candidate <= current_time:
|
||||
return candidate + period
|
||||
return candidate
|
||||
|
||||
|
||||
def _first_of_next_month(first_of_month: datetime) -> datetime:
|
||||
"""Given the 1st of some month, return the 1st of the following month."""
|
||||
if first_of_month.month == 12:
|
||||
return first_of_month.replace(year=first_of_month.year + 1, month=1)
|
||||
return first_of_month.replace(month=first_of_month.month + 1)
|
||||
|
||||
|
||||
def _handle_day_reset(
|
||||
current_time: datetime,
|
||||
base_midnight: datetime,
|
||||
value: int,
|
||||
reset_time_of_day: time,
|
||||
) -> datetime:
|
||||
"""Handle day-based reset times."""
|
||||
# Handle zero value - immediate expiration
|
||||
if value == 0:
|
||||
return current_time
|
||||
|
||||
if value == 1: # Daily reset at midnight
|
||||
return base_midnight + timedelta(days=1)
|
||||
elif value == 7: # Weekly reset on Monday at midnight
|
||||
if value == 1: # Daily reset at the configured time of day
|
||||
return _next_occurrence(base_midnight, reset_time_of_day, current_time, timedelta(days=1))
|
||||
elif value == 7: # Weekly reset on Monday at the configured time of day
|
||||
days_until_monday = (7 - current_time.weekday()) % 7
|
||||
if days_until_monday == 0: # If today is Monday
|
||||
days_until_monday = 7
|
||||
return base_midnight + timedelta(days=days_until_monday)
|
||||
elif value == 30: # Monthly reset on 1st at midnight
|
||||
# Get 1st of next month at midnight
|
||||
if current_time.month == 12:
|
||||
next_reset = datetime(
|
||||
year=current_time.year + 1,
|
||||
month=1,
|
||||
day=1,
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
tzinfo=tz,
|
||||
)
|
||||
else:
|
||||
next_reset = datetime(
|
||||
year=current_time.year,
|
||||
month=current_time.month + 1,
|
||||
day=1,
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
tzinfo=tz,
|
||||
)
|
||||
return next_reset
|
||||
else: # Custom day value - next interval is value days from current
|
||||
return current_time.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=value)
|
||||
upcoming_monday = base_midnight + timedelta(days=days_until_monday)
|
||||
return _next_occurrence(upcoming_monday, reset_time_of_day, current_time, timedelta(days=7))
|
||||
elif value == 30: # Monthly reset on 1st at the configured time of day
|
||||
return _handle_month_reset(current_time, base_midnight, 1, reset_time_of_day)
|
||||
else: # Custom day value - next interval is value days from the start of today
|
||||
return _apply_time_of_day(base_midnight + timedelta(days=value), reset_time_of_day)
|
||||
|
||||
|
||||
def _handle_hour_reset(current_time: datetime, base_midnight: datetime, value: int) -> datetime:
|
||||
|
|
@ -316,36 +353,30 @@ def _handle_second_reset(current_time: datetime, base_midnight: datetime, value:
|
|||
return current_time.replace(hour=next_hour, minute=next_minute, second=next_second, microsecond=0)
|
||||
|
||||
|
||||
def _handle_month_reset(current_time: datetime, base_midnight: datetime, value: int) -> datetime:
|
||||
def _handle_month_reset(
|
||||
current_time: datetime,
|
||||
base_midnight: datetime,
|
||||
value: int,
|
||||
reset_time_of_day: time,
|
||||
) -> datetime:
|
||||
"""
|
||||
Handle monthly reset times. For monthly resets, we always reset at the start of the next month.
|
||||
Handle monthly reset times. Resets land on the 1st at `reset_time_of_day`; if the
|
||||
1st of the current month at that time has already passed, roll to the 1st of next month.
|
||||
|
||||
Args:
|
||||
current_time: Current datetime
|
||||
base_midnight: Midnight of current day
|
||||
value: Number of months (currently only supports 1 month resets)
|
||||
reset_time_of_day: Wall-clock time the reset lands on
|
||||
|
||||
Returns:
|
||||
datetime: First day of next month at midnight
|
||||
datetime: First day of the next reset month at `reset_time_of_day`
|
||||
"""
|
||||
if value != 1:
|
||||
raise ValueError("Monthly resets currently only support 1 month intervals")
|
||||
|
||||
# Get the first day of next month
|
||||
if current_time.month == 12:
|
||||
next_month = 1
|
||||
next_year = current_time.year + 1
|
||||
else:
|
||||
next_month = current_time.month + 1
|
||||
next_year = current_time.year
|
||||
|
||||
return datetime(
|
||||
year=next_year,
|
||||
month=next_month,
|
||||
day=1,
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
tzinfo=current_time.tzinfo,
|
||||
)
|
||||
first_of_this_month = base_midnight.replace(day=1)
|
||||
candidate = _apply_time_of_day(first_of_this_month, reset_time_of_day)
|
||||
if candidate <= current_time:
|
||||
return _apply_time_of_day(_first_of_next_month(first_of_this_month), reset_time_of_day)
|
||||
return candidate
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from litellm.types.utils import (
|
|||
)
|
||||
|
||||
if TYPE_CHECKING: # newer pattern to avoid importing pydantic objects on __init__.py
|
||||
from litellm.types.llms.anthropic import AnthropicInputSchema
|
||||
from litellm.types.llms.openai import ChatCompletionImageObject
|
||||
|
||||
DEFAULT_USER_CONTINUE_MESSAGE = ChatCompletionUserMessage(content="Please continue.", role="user")
|
||||
|
|
@ -1046,6 +1047,31 @@ def unpack_legacy_defs(
|
|||
return schema
|
||||
|
||||
|
||||
def sanitize_input_schema_for_anthropic(input_schema: dict) -> "AnthropicInputSchema":
|
||||
"""Coerce an arbitrary tool input_schema into the shape Anthropic accepts.
|
||||
|
||||
Anthropic requires ``type == "object"``, only recognises ``$defs`` (legacy
|
||||
``definitions`` / OpenAPI ``components.schemas`` refs must be inlined first),
|
||||
and rejects keys outside ``AnthropicInputSchema``. Both the chat
|
||||
(``AnthropicConfig._map_tool_helper``) and Anthropic Messages MCP paths run
|
||||
a schema through here so an external MCP schema cannot succeed on one route
|
||||
and 400 on the other.
|
||||
"""
|
||||
from litellm.types.llms.anthropic import AnthropicInputSchema
|
||||
|
||||
normalized = dict(input_schema) if input_schema else {}
|
||||
if normalized.get("type") != "object":
|
||||
normalized["type"] = "object"
|
||||
if "properties" not in normalized:
|
||||
normalized["properties"] = {}
|
||||
|
||||
normalized = unpack_legacy_defs(normalized, copy=True)
|
||||
|
||||
allowed_keys = set(AnthropicInputSchema.__annotations__.keys())
|
||||
filtered = {key: value for key, value in normalized.items() if key in allowed_keys}
|
||||
return AnthropicInputSchema(**filtered)
|
||||
|
||||
|
||||
def _get_image_mime_type_from_url(url: str) -> Optional[str]:
|
||||
"""
|
||||
Get mime type for common image URLs
|
||||
|
|
|
|||
|
|
@ -148,6 +148,15 @@ def _parse_url_destination_allowlist_entry(
|
|||
return _normalize_host(parsed.hostname), scheme, port
|
||||
|
||||
|
||||
def provider_url_destination_candidates(value: str) -> Tuple[str, ...]:
|
||||
return tuple(
|
||||
candidate
|
||||
for part in value.split(",")
|
||||
for candidate in (part.strip(), part.strip().split("/", 1)[1] if "/" in part.strip() else "")
|
||||
if candidate
|
||||
)
|
||||
|
||||
|
||||
def is_url_destination_allowed_by_host(url: str, allowed_hosts: List[str]) -> bool:
|
||||
"""Return True when a credential-bearing provider URL is admin-allowlisted.
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ from litellm.constants import (
|
|||
RESPONSE_FORMAT_TOOL_NAME,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import map_finish_reason
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import unpack_legacy_defs
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
sanitize_input_schema_for_anthropic,
|
||||
)
|
||||
from litellm.llms.base_llm.base_utils import type_to_response_format_param
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
|
||||
from litellm.types.llms.anthropic import (
|
||||
|
|
@ -478,10 +480,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
"""
|
||||
Filter out unsupported fields from JSON schema for Anthropic's output_format API.
|
||||
|
||||
Anthropic's output_format doesn't support certain JSON schema properties:
|
||||
- maxItems/minItems: Not supported for array types
|
||||
- minimum/maximum: Not supported for numeric types
|
||||
- minLength/maxLength: Not supported for string types
|
||||
Anthropic's output_format doesn't support certain JSON schema properties.
|
||||
These are constraints that cannot be enforced by the constrained-decoding
|
||||
grammar Anthropic compiles the schema into, so the API rejects them with a
|
||||
400 ``invalid_request_error`` (e.g. "output_format.schema: For 'array' type,
|
||||
property 'uniqueItems' is not supported"):
|
||||
- maxItems/minItems/uniqueItems/contains/minContains/maxContains/prefixItems: array constraints
|
||||
- minimum/maximum/exclusiveMinimum/exclusiveMaximum/multipleOf: numeric constraints
|
||||
- minLength/maxLength: string constraints
|
||||
- minProperties/maxProperties/patternProperties/propertyNames: object constraints
|
||||
- dependentRequired/dependentSchemas/unevaluatedProperties: object constraints
|
||||
- if/then/else/not: conditional and negation keywords
|
||||
|
||||
``oneOf`` is also rejected ("Schema type 'oneOf' is not supported") and is
|
||||
rewritten to ``anyOf``, matching the Anthropic SDK. Unknown keywords are
|
||||
ignored by the API, so anything not listed here passes through untouched.
|
||||
|
||||
This mirrors the transformation done by the Anthropic Python SDK.
|
||||
See: https://platform.claude.com/docs/en/build-with-claude/structured-outputs#how-sdk-transformation-works
|
||||
|
|
@ -502,33 +515,53 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# All numeric/string/array constraints not supported by Anthropic
|
||||
unsupported_fields = {
|
||||
"maxItems",
|
||||
"minItems", # array constraints
|
||||
"minimum",
|
||||
"maximum", # numeric constraints
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum", # numeric constraints
|
||||
"minLength",
|
||||
"maxLength", # string constraints
|
||||
}
|
||||
|
||||
# Build description additions from removed constraints
|
||||
constraint_descriptions: list = []
|
||||
constraint_labels = {
|
||||
"minItems": "minimum number of items: {}",
|
||||
"maxItems": "maximum number of items: {}",
|
||||
"uniqueItems": "all array items must be unique",
|
||||
"contains": "array must contain an item matching: {}",
|
||||
"minContains": "minimum number of matching items: {}",
|
||||
"maxContains": "maximum number of matching items: {}",
|
||||
"prefixItems": "leading items must match, in order: {}",
|
||||
"minimum": "minimum value: {}",
|
||||
"maximum": "maximum value: {}",
|
||||
"exclusiveMinimum": "exclusive minimum value: {}",
|
||||
"exclusiveMaximum": "exclusive maximum value: {}",
|
||||
"multipleOf": "must be a multiple of {}",
|
||||
"minLength": "minimum length: {}",
|
||||
"maxLength": "maximum length: {}",
|
||||
"minProperties": "minimum number of properties: {}",
|
||||
"maxProperties": "maximum number of properties: {}",
|
||||
"patternProperties": "properties whose names match each pattern must satisfy: {}",
|
||||
"propertyNames": "property names must satisfy: {}",
|
||||
"dependentRequired": "dependent required properties: {}",
|
||||
"dependentSchemas": "dependent schemas: {}",
|
||||
"unevaluatedProperties": "unevaluated properties must satisfy: {}",
|
||||
"if": "conditional (if): {}",
|
||||
"then": "conditional (then): {}",
|
||||
"else": "conditional (else): {}",
|
||||
"not": "must not match: {}",
|
||||
}
|
||||
for field in unsupported_fields:
|
||||
if field in schema:
|
||||
constraint_descriptions.append(constraint_labels[field].format(schema[field]))
|
||||
unsupported_fields = set(constraint_labels)
|
||||
|
||||
# Build description additions from removed constraints. Iterating
|
||||
# constraint_labels (not the set) keeps the note order deterministic across
|
||||
# processes, so identical requests serialize identically regardless of
|
||||
# PYTHONHASHSEED and stay cache-friendly.
|
||||
constraint_descriptions: list = []
|
||||
for field, label in constraint_labels.items():
|
||||
if field not in schema:
|
||||
continue
|
||||
value = schema[field]
|
||||
# A falsy boolean constraint (e.g. ``uniqueItems: false``) imposes no
|
||||
# real requirement, so don't add a misleading advisory note for it.
|
||||
if isinstance(value, bool) and not value:
|
||||
continue
|
||||
# Sub-schema constraints (e.g. ``contains``) are serialized as JSON so
|
||||
# the advisory note preserves what the constraint actually required,
|
||||
# instead of just noting that it existed.
|
||||
note_value = json.dumps(value) if isinstance(value, (dict, list)) else value
|
||||
constraint_descriptions.append(label.format(note_value))
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
|
|
@ -555,11 +588,17 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
elif key == "$defs" and isinstance(value, dict):
|
||||
result[key] = {k: AnthropicConfig.filter_anthropic_output_schema(v) for k, v in value.items()}
|
||||
elif key == "anyOf" and isinstance(value, list):
|
||||
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
|
||||
result["anyOf"] = result.get("anyOf", []) + [
|
||||
AnthropicConfig.filter_anthropic_output_schema(item) for item in value
|
||||
]
|
||||
elif key == "allOf" and isinstance(value, list):
|
||||
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
|
||||
elif key == "oneOf" and isinstance(value, list):
|
||||
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
|
||||
# Anthropic rejects oneOf ("Schema type 'oneOf' is not supported");
|
||||
# the Anthropic SDK rewrites it to anyOf, so do the same.
|
||||
result["anyOf"] = result.get("anyOf", []) + [
|
||||
AnthropicConfig.filter_anthropic_output_schema(item) for item in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
|
|
@ -634,7 +673,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
mcp_server: Optional[AnthropicMcpServerTool] = None
|
||||
|
||||
if tool["type"] == "function" or tool["type"] == "custom":
|
||||
_input_schema: dict = tool["function"].get(
|
||||
_input_schema = tool["function"].get(
|
||||
"parameters",
|
||||
{
|
||||
"type": "object",
|
||||
|
|
@ -642,28 +681,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
},
|
||||
)
|
||||
|
||||
# Anthropic requires input_schema.type to be "object". Normalize
|
||||
# schemas from external sources (MCP servers, OpenAI callers) that
|
||||
# may omit the type field or use a non-object type.
|
||||
if _input_schema.get("type") != "object":
|
||||
litellm.verbose_logger.debug(
|
||||
"_map_tool_helper: coercing input_schema type from %r to "
|
||||
"'object' for Anthropic compatibility (tool: %s)",
|
||||
_input_schema.get("type"),
|
||||
tool["function"].get("name"),
|
||||
)
|
||||
_input_schema = dict(_input_schema) # avoid mutating caller's dict
|
||||
_input_schema["type"] = "object"
|
||||
if "properties" not in _input_schema:
|
||||
_input_schema["properties"] = {}
|
||||
|
||||
# Inline legacy / OpenAPI $refs before the allow-list filter strips
|
||||
# their backing def blocks (https://github.com/BerriAI/litellm/issues/26692).
|
||||
_input_schema = unpack_legacy_defs(_input_schema, copy=True)
|
||||
|
||||
_allowed_properties = set(AnthropicInputSchema.__annotations__.keys())
|
||||
input_schema_filtered = {k: v for k, v in _input_schema.items() if k in _allowed_properties}
|
||||
input_anthropic_schema: AnthropicInputSchema = AnthropicInputSchema(**input_schema_filtered)
|
||||
input_anthropic_schema = sanitize_input_schema_for_anthropic(_input_schema)
|
||||
|
||||
_tool = AnthropicMessagesTool(
|
||||
name=tool["function"]["name"],
|
||||
|
|
|
|||
|
|
@ -485,6 +485,41 @@ def anthropic_messages_handler(
|
|||
mock_response=litellm_params.mock_response,
|
||||
)
|
||||
|
||||
# Expand litellm_proxy MCP references through the MCP gateway before dispatch, so every
|
||||
# downstream path (native passthrough and both bridges) gets real tools rather than a
|
||||
# reference the provider cannot resolve. Popped from kwargs so it never reaches the provider.
|
||||
skip_mcp_handler = kwargs.pop("_skip_mcp_handler", False)
|
||||
if not skip_mcp_handler and tools:
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler import (
|
||||
anthropic_messages_with_mcp,
|
||||
)
|
||||
from litellm.responses.mcp.litellm_proxy_mcp_handler import (
|
||||
LiteLLM_Proxy_MCP_Handler,
|
||||
)
|
||||
|
||||
if LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(tools=tools):
|
||||
return anthropic_messages_with_mcp(
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
model=model,
|
||||
metadata=metadata,
|
||||
stop_sequences=stop_sequences,
|
||||
stream=stream,
|
||||
system=system,
|
||||
temperature=temperature,
|
||||
thinking=thinking,
|
||||
tool_choice=tool_choice,
|
||||
tools=tools,
|
||||
top_k=top_k,
|
||||
top_p=top_p,
|
||||
container=container,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
client=client,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
anthropic_messages_provider_config: Optional[BaseAnthropicMessagesConfig] = None
|
||||
|
||||
if custom_llm_provider is not None and custom_llm_provider in [provider.value for provider in LlmProviders]:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
"""
|
||||
MCP gateway support for the Anthropic `/v1/messages` API.
|
||||
|
||||
Mirrors ``litellm.responses.mcp.chat_completions_handler`` but speaks the
|
||||
Anthropic Messages shapes: tools carry an ``input_schema``, the model asks for a
|
||||
tool through a ``tool_use`` content block, and results are fed back as
|
||||
``tool_result`` blocks in a user message.
|
||||
"""
|
||||
|
||||
from typing import Any, AsyncIterator, Mapping, Sequence, Union
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.responses.mcp.request_context import MCPRequestContext
|
||||
from litellm.types.llms.anthropic import (
|
||||
AnthropicMessagesTool,
|
||||
AnthropicMessagesToolResultParam,
|
||||
AnthropicMessagesUserMessageParam,
|
||||
)
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import (
|
||||
AnthropicMessagesResponse,
|
||||
)
|
||||
|
||||
MAX_MCP_TOOL_USE_ITERATIONS = 10
|
||||
|
||||
|
||||
def _get_response_content(response: AnthropicMessagesResponse) -> Sequence[Mapping[str, Any]]:
|
||||
content = response.get("content")
|
||||
if not isinstance(content, list):
|
||||
return ()
|
||||
return tuple(block for block in content if isinstance(block, dict))
|
||||
|
||||
|
||||
def _extract_tool_use_blocks(response: AnthropicMessagesResponse) -> Sequence[Mapping[str, Any]]:
|
||||
"""Return the ``tool_use`` content blocks the model emitted."""
|
||||
return tuple(block for block in _get_response_content(response) if block.get("type") == "tool_use")
|
||||
|
||||
|
||||
def _get_stop_reason(response: AnthropicMessagesResponse) -> Union[str, None]:
|
||||
stop_reason = response.get("stop_reason")
|
||||
return stop_reason if isinstance(stop_reason, str) else None
|
||||
|
||||
|
||||
def _build_tool_result_message(tool_results: Sequence[Mapping[str, Any]]) -> AnthropicMessagesUserMessageParam:
|
||||
"""Turn executed tool results into the user message Anthropic expects."""
|
||||
return AnthropicMessagesUserMessageParam(
|
||||
role="user",
|
||||
content=tuple(
|
||||
AnthropicMessagesToolResultParam(
|
||||
type="tool_result",
|
||||
tool_use_id=str(result.get("tool_call_id") or ""),
|
||||
content=str(result.get("result") or ""),
|
||||
)
|
||||
for result in tool_results
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def anthropic_messages_with_mcp(
|
||||
max_tokens: int,
|
||||
messages: Sequence[Mapping[str, Any]],
|
||||
model: str,
|
||||
tools: Union[Sequence[Mapping[str, Any]], None] = None,
|
||||
**kwargs: Any, # kwargs-ok: forwarded verbatim to litellm.anthropic_messages, which owns the param contract
|
||||
) -> Union[AnthropicMessagesResponse, AsyncIterator[Any]]:
|
||||
"""
|
||||
Expand litellm_proxy MCP references for `/v1/messages` and run the tool loop.
|
||||
|
||||
The MCP gateway owns the expansion so the reference resolves against the
|
||||
caller's own credentials and access control, rather than being handed to the
|
||||
upstream provider as a url it cannot reach.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.experimental_mcp_client.tools import (
|
||||
transform_mcp_tool_to_anthropic_tool,
|
||||
)
|
||||
from litellm.responses.mcp.litellm_proxy_mcp_handler import (
|
||||
LiteLLM_Proxy_MCP_Handler,
|
||||
)
|
||||
|
||||
mcp_references, other_tools = LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(tools)
|
||||
|
||||
if not mcp_references:
|
||||
return await litellm.anthropic_messages(
|
||||
max_tokens=max_tokens,
|
||||
messages=list(messages),
|
||||
model=model,
|
||||
tools=list(tools) if tools else None,
|
||||
_skip_mcp_handler=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
context = MCPRequestContext.resolve(kwargs=dict(kwargs), tools=tools)
|
||||
|
||||
(
|
||||
deduplicated_mcp_tools,
|
||||
tool_server_map,
|
||||
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
|
||||
context.user_api_key_auth,
|
||||
mcp_references,
|
||||
litellm_trace_id=context.litellm_trace_id,
|
||||
mcp_auth_header=context.mcp_auth_header,
|
||||
mcp_server_auth_headers=context.mcp_server_auth_headers,
|
||||
request_tags=list(context.request_tags) if context.request_tags else None,
|
||||
)
|
||||
|
||||
anthropic_tools: Sequence[AnthropicMessagesTool] = tuple(
|
||||
transform_mcp_tool_to_anthropic_tool(mcp_tool) for mcp_tool in deduplicated_mcp_tools
|
||||
)
|
||||
all_tools = [*anthropic_tools, *(other_tools or ())]
|
||||
|
||||
should_auto_execute = LiteLLM_Proxy_MCP_Handler._should_auto_execute_tools(
|
||||
mcp_tools_with_litellm_proxy=mcp_references
|
||||
)
|
||||
stream = bool(kwargs.pop("stream", False))
|
||||
|
||||
base_call_args: Mapping[str, Any] = {
|
||||
"max_tokens": max_tokens,
|
||||
"model": model,
|
||||
"tools": all_tools or None,
|
||||
"_skip_mcp_handler": True,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
if not should_auto_execute:
|
||||
return await litellm.anthropic_messages(messages=list(messages), stream=stream, **base_call_args)
|
||||
|
||||
working_messages: Sequence[Mapping[str, Any]] = tuple(messages)
|
||||
response: AnthropicMessagesResponse = await litellm.anthropic_messages(
|
||||
messages=list(working_messages), stream=False, **base_call_args
|
||||
)
|
||||
|
||||
for _ in range(MAX_MCP_TOOL_USE_ITERATIONS):
|
||||
if _get_stop_reason(response) != "tool_use":
|
||||
break
|
||||
|
||||
tool_use_blocks = _extract_tool_use_blocks(response)
|
||||
if not tool_use_blocks:
|
||||
break
|
||||
|
||||
tool_results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
|
||||
tool_server_map=tool_server_map,
|
||||
tool_calls=list(tool_use_blocks),
|
||||
user_api_key_auth=context.user_api_key_auth,
|
||||
mcp_auth_header=context.mcp_auth_header,
|
||||
mcp_server_auth_headers=context.mcp_server_auth_headers,
|
||||
oauth2_headers=context.oauth2_headers,
|
||||
raw_headers=context.raw_headers,
|
||||
litellm_call_id=context.litellm_call_id,
|
||||
litellm_trace_id=context.litellm_trace_id,
|
||||
request_tags=list(context.request_tags) if context.request_tags else None,
|
||||
)
|
||||
|
||||
# Every tool call was skipped, so there is nothing to feed back; a
|
||||
# tool_result message with empty content is rejected by Anthropic.
|
||||
if not tool_results:
|
||||
break
|
||||
|
||||
working_messages = (
|
||||
*working_messages,
|
||||
{"role": "assistant", "content": list(_get_response_content(response))},
|
||||
_build_tool_result_message(tool_results),
|
||||
)
|
||||
response = await litellm.anthropic_messages(messages=list(working_messages), stream=False, **base_call_args)
|
||||
else:
|
||||
verbose_logger.warning(
|
||||
f"MCP tool loop hit its {MAX_MCP_TOOL_USE_ITERATIONS} iteration cap for model {model}; "
|
||||
"returning the last response"
|
||||
)
|
||||
|
||||
if stream:
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.fake_stream_iterator import (
|
||||
FakeAnthropicMessagesStreamIterator,
|
||||
)
|
||||
|
||||
return FakeAnthropicMessagesStreamIterator(response)
|
||||
return response
|
||||
|
|
@ -144,6 +144,76 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
else:
|
||||
return system_param
|
||||
|
||||
@staticmethod
|
||||
def _as_system_content_blocks(value: Any) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return list(value)
|
||||
if isinstance(value, str):
|
||||
return [{"type": "text", "text": value}]
|
||||
return [value]
|
||||
|
||||
@staticmethod
|
||||
def _is_system_role_message(message: Any) -> bool:
|
||||
return isinstance(message, dict) and message.get("role") == "system"
|
||||
|
||||
def _normalize_system_role_messages(self, anthropic_messages_request: dict, model: str) -> None:
|
||||
"""Move ``role: "system"`` entries out of ``messages`` per the Anthropic
|
||||
``/v1/messages`` contract, which the first-party API, Bedrock Invoke,
|
||||
Vertex, and Azure Foundry all enforce identically.
|
||||
|
||||
A *leading* run of system entries is rejected on every model ("messages.0:
|
||||
use the top-level 'system' parameter for the initial system prompt") and
|
||||
must be hoisted into the top-level ``system`` field. Models flagged
|
||||
``supports_mid_conversation_system`` in the cost map (Claude 4.8+ and the
|
||||
5 family) accept a *mid-conversation* entry (e.g. Claude Code's
|
||||
``mid-conversation-system-2026-04-07`` reminders) in place, where it MUST
|
||||
stay: hoisting one mutates the ``system`` prefix and invalidates the
|
||||
prompt cache for the whole message history. Older Claude models reject the
|
||||
role in every position ("role 'system' is not supported on this model"),
|
||||
so without the flag every system entry is hoisted to keep the request from
|
||||
400-ing. Billing-header system blocks are stripped from the top-level
|
||||
``system`` field regardless of whether anything was hoisted.
|
||||
|
||||
Subclasses whose upstream rejects the role opt in by calling this from
|
||||
their ``transform_anthropic_messages_request``; the first-party Anthropic
|
||||
path forwards ``messages`` untouched and never calls it."""
|
||||
from litellm.utils import _supports_factory
|
||||
|
||||
messages = anthropic_messages_request.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
if _supports_factory(
|
||||
model=model,
|
||||
custom_llm_provider=self.custom_llm_provider,
|
||||
key="supports_mid_conversation_system",
|
||||
):
|
||||
leading_count = next(
|
||||
(i for i, m in enumerate(messages) if not self._is_system_role_message(m)),
|
||||
len(messages),
|
||||
)
|
||||
hoisted = messages[:leading_count]
|
||||
remaining = messages[leading_count:]
|
||||
else:
|
||||
hoisted = [m for m in messages if self._is_system_role_message(m)]
|
||||
remaining = [m for m in messages if not self._is_system_role_message(m)]
|
||||
if hoisted:
|
||||
anthropic_messages_request["messages"] = remaining
|
||||
system_content = [
|
||||
block
|
||||
for source in (
|
||||
anthropic_messages_request.get("system"),
|
||||
*(m.get("content") for m in hoisted),
|
||||
)
|
||||
for block in self._as_system_content_blocks(source)
|
||||
]
|
||||
filtered_system = self._filter_billing_headers_from_system(system_content)
|
||||
if filtered_system:
|
||||
anthropic_messages_request["system"] = filtered_system
|
||||
else:
|
||||
anthropic_messages_request.pop("system", None)
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: Optional[str],
|
||||
|
|
|
|||
|
|
@ -166,5 +166,6 @@ class AzureAnthropicMessagesConfig(AnthropicMessagesConfig):
|
|||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
self._normalize_system_role_messages(anthropic_messages_request, model=model)
|
||||
self._remove_scope_from_cache_control(anthropic_messages_request)
|
||||
return anthropic_messages_request
|
||||
|
|
|
|||
84
litellm/llms/bedrock/audio_transcription/__init__.py
Normal file
84
litellm/llms/bedrock/audio_transcription/__init__.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import base64
|
||||
from typing import Union
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.litellm_core_utils.audio_utils.utils import process_audio_file
|
||||
from litellm.rust_bridge import transcription as rust_transcription_bridge
|
||||
from litellm.types.utils import FileTypes, TranscriptionResponse
|
||||
|
||||
|
||||
class BedrockAudioTranscriptionRustDispatch:
|
||||
@staticmethod
|
||||
def _audio_payload(audio_file: FileTypes) -> dict[str, object]:
|
||||
processed_audio = process_audio_file(audio_file)
|
||||
formats = {
|
||||
"audio/flac": "flac",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp3": "mp3",
|
||||
"audio/ogg": "ogg",
|
||||
"audio/wav": "wav",
|
||||
"audio/x-wav": "wav",
|
||||
}
|
||||
audio_format = formats.get(processed_audio.content_type) or (
|
||||
processed_audio.filename.rsplit(".", 1)[-1].lower() if "." in processed_audio.filename else ""
|
||||
)
|
||||
if audio_format not in {"wav", "mp3", "flac", "ogg"}:
|
||||
raise ValueError(f"Unsupported Bedrock audio format for file {processed_audio.filename!r}")
|
||||
return {
|
||||
"data": base64.b64encode(processed_audio.file_content).decode("ascii"),
|
||||
"format": audio_format,
|
||||
"filename": processed_audio.filename,
|
||||
}
|
||||
|
||||
def audio_transcriptions(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
audio_file: FileTypes,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
custom_llm_provider: str,
|
||||
extra_headers: dict[str, object] | None,
|
||||
optional_params: dict[str, object],
|
||||
timeout: Union[float, httpx.Timeout] | None,
|
||||
) -> TranscriptionResponse:
|
||||
rust_response = rust_transcription_bridge.transcription(
|
||||
model=model,
|
||||
audio=self._audio_payload(audio_file),
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
extra_headers=extra_headers,
|
||||
optional_params=optional_params,
|
||||
timeout=timeout,
|
||||
)
|
||||
if rust_response is None:
|
||||
raise RuntimeError("Rust audio transcription bridge is unavailable")
|
||||
return TranscriptionResponse(**rust_response)
|
||||
|
||||
async def async_audio_transcriptions(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
audio_file: FileTypes,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
custom_llm_provider: str,
|
||||
extra_headers: dict[str, object] | None,
|
||||
optional_params: dict[str, object],
|
||||
timeout: Union[float, httpx.Timeout] | None,
|
||||
) -> TranscriptionResponse:
|
||||
rust_response = await rust_transcription_bridge.atranscription(
|
||||
model=model,
|
||||
audio=self._audio_payload(audio_file),
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
extra_headers=extra_headers,
|
||||
optional_params=optional_params,
|
||||
timeout=timeout,
|
||||
)
|
||||
if rust_response is None:
|
||||
raise RuntimeError("Rust audio transcription bridge is unavailable")
|
||||
return TranscriptionResponse(**rust_response)
|
||||
|
|
@ -4,6 +4,7 @@ import time
|
|||
from typing import Any, Dict, List, Literal, Optional, Union, cast
|
||||
|
||||
from httpx import Headers, Response
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.litellm_core_utils.cloud_storage_security import (
|
||||
BEDROCK_MANAGED_S3_BATCH_PREFIX,
|
||||
|
|
@ -19,6 +20,7 @@ from litellm.types.llms.bedrock import (
|
|||
BedrockOutputDataConfig,
|
||||
BedrockS3InputDataConfig,
|
||||
BedrockS3OutputDataConfig,
|
||||
BedrockTag,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
|
|
@ -38,6 +40,18 @@ _S3_BATCH_FILE_UUID_SUFFIX_PATTERN = re.compile(
|
|||
r"-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.jsonl$"
|
||||
)
|
||||
|
||||
_BEDROCK_TAGS_ADAPTER: TypeAdapter[list[BedrockTag]] = TypeAdapter(list[BedrockTag])
|
||||
|
||||
|
||||
def _validate_bedrock_tags(raw_tags: object) -> list[BedrockTag]:
|
||||
try:
|
||||
return _BEDROCK_TAGS_ADAPTER.validate_python(raw_tags, strict=True)
|
||||
except ValidationError as e:
|
||||
raise ValueError(
|
||||
"Invalid 'bedrock_tags' value. Expected a list of {'key': <str>, 'value': <str>} dicts, "
|
||||
f"e.g. [{{'key': 'team', 'value': 'genai'}}]. Got: {raw_tags!r}"
|
||||
) from e
|
||||
|
||||
|
||||
class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig):
|
||||
"""
|
||||
|
|
@ -201,6 +215,11 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig):
|
|||
"roleArn": role_arn,
|
||||
}
|
||||
|
||||
config_bedrock_tags = litellm_params.get("bedrock_tags")
|
||||
bedrock_tags = config_bedrock_tags if config_bedrock_tags is not None else optional_params.get("bedrock_tags")
|
||||
if bedrock_tags is not None:
|
||||
bedrock_request["tags"] = _validate_bedrock_tags(bedrock_tags)
|
||||
|
||||
# Add optional parameters if provided
|
||||
completion_window = create_batch_data.get("completion_window")
|
||||
if completion_window:
|
||||
|
|
|
|||
|
|
@ -895,9 +895,8 @@ class AmazonConverseConfig(BaseConfig):
|
|||
if _tool_choice_value is not None:
|
||||
optional_params["tool_choice"] = _tool_choice_value
|
||||
if param == "parallel_tool_calls":
|
||||
disable_parallel = not value
|
||||
optional_params["_parallel_tool_use_config"] = {
|
||||
"tool_choice": {"disable_parallel_tool_use": disable_parallel}
|
||||
"tool_choice": {"type": "auto", "disable_parallel_tool_use": not value}
|
||||
}
|
||||
if param == "thinking":
|
||||
if (
|
||||
|
|
@ -1208,6 +1207,22 @@ class AmazonConverseConfig(BaseConfig):
|
|||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _merge_parallel_tool_use_config(additional_request_params: dict, parallel_tool_use_config: dict) -> dict:
|
||||
merged_entries = {
|
||||
key: (
|
||||
{
|
||||
**value,
|
||||
**additional_request_params[key],
|
||||
**{k: v for k, v in value.items() if k != "type"},
|
||||
}
|
||||
if isinstance(additional_request_params.get(key), dict) and isinstance(value, dict)
|
||||
else value
|
||||
)
|
||||
for key, value in parallel_tool_use_config.items()
|
||||
}
|
||||
return {**additional_request_params, **merged_entries}
|
||||
|
||||
def _prepare_request_params(
|
||||
self, optional_params: dict, model: str, drop_params: bool = False
|
||||
) -> Tuple[dict, dict, dict, Optional[OutputConfigBlock]]:
|
||||
|
|
@ -1276,15 +1291,9 @@ class AmazonConverseConfig(BaseConfig):
|
|||
# Handle parallel_tool_calls configuration
|
||||
parallel_tool_use_config = additional_request_params.pop("_parallel_tool_use_config", None)
|
||||
if parallel_tool_use_config is not None and bedrock_converse_supports_parallel_tool_use_config(model):
|
||||
for key, value in parallel_tool_use_config.items():
|
||||
if (
|
||||
key in additional_request_params
|
||||
and isinstance(additional_request_params[key], dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
additional_request_params[key].update(value)
|
||||
else:
|
||||
additional_request_params[key] = value
|
||||
additional_request_params = self._merge_parallel_tool_use_config(
|
||||
additional_request_params, parallel_tool_use_config
|
||||
)
|
||||
|
||||
additional_request_params.pop("parallel_tool_calls", None)
|
||||
|
||||
|
|
|
|||
|
|
@ -87,67 +87,6 @@ class AmazonAnthropicClaudeMessagesConfig(
|
|||
BaseAnthropicMessagesConfig.__init__(self, **kwargs)
|
||||
AmazonInvokeConfig.__init__(self, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _as_system_content_blocks(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return list(value)
|
||||
if isinstance(value, str):
|
||||
return [{"type": "text", "text": value}]
|
||||
return [value]
|
||||
|
||||
@staticmethod
|
||||
def _is_system_role_message(message: Any) -> bool:
|
||||
return isinstance(message, dict) and message.get("role") == "system"
|
||||
|
||||
def _normalize_system_role_messages_for_bedrock(self, anthropic_messages_request: dict, model: str) -> None:
|
||||
"""Bedrock Invoke validates ``role: "system"`` entries inside ``messages``
|
||||
per model. Models carrying ``supports_mid_conversation_system`` in the
|
||||
cost map (the Opus 4.8 family) only reject a leading run ("messages.0:
|
||||
use the top-level 'system' parameter for the initial system prompt") and
|
||||
accept mid-conversation entries (e.g. Claude Code's
|
||||
``mid-conversation-system-2026-04-07`` reminders) in place, where they
|
||||
MUST stay: hoisting one mutates the ``system`` prefix and invalidates the
|
||||
prompt cache for the entire message history. Older Claude models (Opus
|
||||
4.7, Sonnet 4.6, Haiku 4.5, ...) reject the role in every position
|
||||
("role 'system' is not supported on this model"), so without the flag
|
||||
every system entry is hoisted into the top-level ``system`` field.
|
||||
Billing-header system blocks are stripped from the top-level ``system``
|
||||
field regardless of whether anything was hoisted."""
|
||||
messages = anthropic_messages_request.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
if _supports_factory(
|
||||
model=model,
|
||||
custom_llm_provider="bedrock",
|
||||
key="supports_mid_conversation_system",
|
||||
):
|
||||
leading_count = next(
|
||||
(i for i, m in enumerate(messages) if not self._is_system_role_message(m)),
|
||||
len(messages),
|
||||
)
|
||||
hoisted = messages[:leading_count]
|
||||
remaining = messages[leading_count:]
|
||||
else:
|
||||
hoisted = [m for m in messages if self._is_system_role_message(m)]
|
||||
remaining = [m for m in messages if not self._is_system_role_message(m)]
|
||||
if hoisted:
|
||||
anthropic_messages_request["messages"] = remaining
|
||||
system_content = [
|
||||
block
|
||||
for source in (
|
||||
anthropic_messages_request.get("system"),
|
||||
*(m.get("content") for m in hoisted),
|
||||
)
|
||||
for block in self._as_system_content_blocks(source)
|
||||
]
|
||||
filtered_system = self._filter_billing_headers_from_system(system_content)
|
||||
if filtered_system:
|
||||
anthropic_messages_request["system"] = filtered_system
|
||||
else:
|
||||
anthropic_messages_request.pop("system", None)
|
||||
|
||||
def validate_anthropic_messages_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
|
|
@ -696,7 +635,7 @@ class AmazonAnthropicClaudeMessagesConfig(
|
|||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
self._normalize_system_role_messages_for_bedrock(anthropic_messages_request, model=model)
|
||||
self._normalize_system_role_messages(anthropic_messages_request, model=model)
|
||||
#########################################################
|
||||
############## BEDROCK Invoke SPECIFIC TRANSFORMATION ###
|
||||
#########################################################
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue