Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_bedrock_passthrough_content_length_regression

This commit is contained in:
mateo-berri 2026-06-08 19:35:19 +00:00
commit 3098992470
No known key found for this signature in database
1389 changed files with 140595 additions and 26819 deletions

View file

@ -111,6 +111,28 @@ commands:
- wait_for_service:
url: tcp://localhost:6379
timeout: "60"
start_openai_record_replay_proxy:
description: "Start the record/replay proxy (tests/_openai_record_replay_proxy.py) on host port 8090 and wait until healthy. Models whose api_base points here replay recorded provider responses, so the E2E run neither pays for nor depends on the live provider. The default upstream is OpenAI; a non-OpenAI model must point its api_base at /__recorder_upstream/<host>/ so the recorder forwards there instead of defaulting to OpenAI. Run after uv deps are synced."
steps:
- run:
name: Start record/replay proxy
background: true
command: |
CASSETTE_REDIS_URL="$CASSETTE_REDIS_URL" \
RECORDER_UPSTREAM_BASE_URL="https://api.openai.com" \
uv run --no-sync python tests/_openai_record_replay_proxy.py --host 0.0.0.0 --port 8090
- run:
name: Wait for record/replay proxy
command: |
for i in $(seq 1 30); do
if curl -sf http://localhost:8090/__recorder_health >/dev/null 2>&1; then
echo "record/replay proxy is up"
exit 0
fi
sleep 1
done
echo "record/replay proxy did not become ready" >&2
exit 1
setup_litellm_enterprise_pip:
steps:
- run:
@ -182,7 +204,14 @@ jobs:
- run:
name: Run Windows-specific test
command: |
uv run --no-sync python -m pytest tests/windows_tests/test_litellm_on_windows.py -v
uv run --no-sync python -m pytest tests/windows_tests/ -v
- run:
name: Guard against MAX_PATH-busting packaged wheel paths
environment:
UV_HTTP_TIMEOUT: "300"
command: |
uv build --wheel --out-dir dist
uv run --no-sync python tests/windows_tests/check_windows_wheel_install.py
local_testing_part1:
docker:
@ -242,8 +271,15 @@ jobs:
- run:
name: Rename the coverage files
command: |
mv coverage.xml local_testing_part1_coverage.xml
mv .coverage local_testing_part1_coverage
# When CI reruns only the failed tests, a parallel node can receive
# zero tests and pytest never writes coverage. Emit empty placeholders
# so persist_to_workspace and the downstream coverage combine stay green.
if [ -f coverage.xml ]; then
mv coverage.xml local_testing_part1_coverage.xml
mv .coverage local_testing_part1_coverage
else
touch local_testing_part1_coverage.xml local_testing_part1_coverage
fi
# Store test results
- store_test_results:
@ -307,8 +343,15 @@ jobs:
- run:
name: Rename the coverage files
command: |
mv coverage.xml local_testing_part2_coverage.xml
mv .coverage local_testing_part2_coverage
# When CI reruns only the failed tests, a parallel node can receive
# zero tests and pytest never writes coverage. Emit empty placeholders
# so persist_to_workspace and the downstream coverage combine stay green.
if [ -f coverage.xml ]; then
mv coverage.xml local_testing_part2_coverage.xml
mv .coverage local_testing_part2_coverage
else
touch local_testing_part2_coverage.xml local_testing_part2_coverage
fi
# Store test results
- store_test_results:
@ -431,6 +474,120 @@ jobs:
- auth_ui_unit_tests_coverage.xml
- auth_ui_unit_tests_coverage
proxy_behavior_tests:
docker:
- *python312_image
- image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: litellm_test
working_directory: ~/project
environment:
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test"
steps:
- checkout
- setup_google_dns
- install_uv
- run:
name: Install Dependencies
command: |
uv sync --frozen --all-groups --all-extras --python 3.12
- wait_for_service:
url: tcp://localhost:5432
timeout: "60"
- run:
name: Seed DB schema via prisma db push
command: |
uv run --no-sync prisma db push --schema litellm/proxy/schema.prisma --accept-data-loss
- run:
name: Generate Prisma Client
command: uv run --no-sync python -m prisma generate
- run:
name: Run proxy management behavior tests
command: |
mkdir -p test-results
uv run --no-sync python -m pytest tests/proxy_behavior \
-v --junitxml=test-results/junit.xml --durations=10
no_output_timeout: 15m
- store_test_results:
path: test-results
proxy_security_tests:
docker:
- *python312_image
- image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: litellm_test
working_directory: ~/project
environment:
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test"
steps:
- checkout
- setup_google_dns
- install_uv
- run:
name: Install Dependencies
command: |
uv sync --frozen --all-groups --all-extras --python 3.12
- wait_for_service:
url: tcp://localhost:5432
timeout: "60"
- run:
name: Seed DB schema via prisma db push
command: |
uv run --no-sync prisma db push --schema litellm/proxy/schema.prisma --accept-data-loss
- run:
name: Generate Prisma Client
command: uv run --no-sync python -m prisma generate
- run:
name: Run proxy security tests
command: |
mkdir -p test-results
uv run --no-sync python -m pytest tests/proxy_security_tests \
-v --junitxml=test-results/junit.xml --durations=10
no_output_timeout: 15m
- store_test_results:
path: test-results
schema_migration_check:
docker:
- *python312_image
- image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: litellm_test
working_directory: ~/project
environment:
# An empty database; the test applies every committed migration itself.
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/litellm_test"
steps:
- checkout
- setup_google_dns
- install_uv
- run:
name: Install Dependencies
command: |
uv sync --frozen --all-groups --all-extras --python 3.12
- wait_for_service:
url: tcp://localhost:5432
timeout: "60"
- run:
name: Generate Prisma Client
command: uv run --no-sync python -m prisma generate
- run:
name: Check schema.prisma is in sync with committed migrations
command: |
mkdir -p test-results
uv run --no-sync python -m pytest tests/proxy_migration_tests \
-v --junitxml=test-results/junit.xml --durations=10
no_output_timeout: 15m
- store_test_results:
path: test-results
litellm_router_testing: # Runs all tests with the "router" keyword
docker:
- *python312_image
@ -457,6 +614,11 @@ jobs:
- run:
name: Run tests
command: |
# On a "rerun failed tests" build a parallel node can receive no
# tests, so the test command never creates test-results. Pre-create it
# so store_test_results doesn't fail the node on a missing path.
mkdir -p test-results
TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_*.py")
echo "$TEST_FILES" | circleci tests run \
@ -1485,6 +1647,7 @@ jobs:
command: |
zstd -d litellm-docker-database.tar.zst --stdout | docker load
docker tag litellm-docker-database:ci my-app:latest
- start_openai_record_replay_proxy
- run:
name: Run Docker container
command: |
@ -1515,6 +1678,7 @@ jobs:
-e LANGFUSE_PROJECT2_PUBLIC=$LANGFUSE_PROJECT2_PUBLIC \
-e LANGFUSE_PROJECT1_SECRET=$LANGFUSE_PROJECT1_SECRET \
-e LANGFUSE_PROJECT2_SECRET=$LANGFUSE_PROJECT2_SECRET \
-e RECORDER_OPENAI_BASE_URL=http://host.docker.internal:8090/v1 \
--add-host host.docker.internal:host-gateway \
--name my-app \
-v $(pwd)/proxy_server_config.yaml:/app/config.yaml \
@ -1652,6 +1816,7 @@ jobs:
command: |
zstd -d litellm-docker-database.tar.zst --stdout | docker load
docker images | grep litellm-docker-database
- start_openai_record_replay_proxy
- run:
name: Run Docker container
# intentionally give bad redis credentials here
@ -1675,6 +1840,7 @@ jobs:
-e DD_SITE=$DD_SITE \
-e AWS_REGION_NAME=$AWS_REGION_NAME \
-e COHERE_API_KEY=$COHERE_API_KEY \
-e RECORDER_COHERE_BASE_URL=http://host.docker.internal:8090/__recorder_upstream/api.cohere.com \
-e GCS_FLUSH_INTERVAL="1" \
--add-host host.docker.internal:host-gateway \
--name my-app \
@ -2240,6 +2406,7 @@ jobs:
command: |
zstd -d litellm-docker-database.tar.zst --stdout | docker load
docker images | grep litellm-docker-database
- start_openai_record_replay_proxy
- run:
name: Run Docker container with test config
command: |
@ -2248,6 +2415,7 @@ jobs:
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e LITELLM_MASTER_KEY="sk-1234" \
-e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
-e RECORDER_ANTHROPIC_BASE_URL=http://host.docker.internal:8090/__recorder_upstream/api.anthropic.com \
-e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
-e AWS_REGION_NAME="us-east-1" \
@ -2617,6 +2785,12 @@ workflows:
filters: *main_branches
- auth_ui_unit_tests:
filters: *main_branches
- proxy_behavior_tests:
filters: *main_branches
- proxy_security_tests:
filters: *main_branches
- schema_migration_check:
filters: *main_branches
- build_docker_database_image:
filters: *main_branches
- e2e_ui_testing:

View file

@ -8,3 +8,6 @@
# Update pydantic code to fix warnings (GH-3600)
876840e9957bc7e9f7d6a2b58c4d7c53dad16481
# style(ui): run prettier --write across the dashboard (#29622)
7edf3a9cb55548b143df1692f4ed7c4681d7fcf7

3
.gitattributes vendored
View file

@ -1 +1,2 @@
*.ipynb linguist-vendored
*.ipynb linguist-vendored
ui/litellm-dashboard/src/lib/http/schema.d.ts linguist-generated

View file

@ -27,6 +27,11 @@ on:
required: false
type: number
default: 10
dist:
description: "pytest-xdist distribution mode (loadscope|load|worksteal|loadfile|no)"
required: false
type: string
default: "loadscope"
artifact-name:
description: "Unique name for the coverage artifact (must be unique per run)"
required: true
@ -82,18 +87,31 @@ jobs:
MAX_FAILURES: ${{ inputs.max-failures }}
WORKERS: ${{ inputs.workers }}
RERUNS: ${{ inputs.reruns }}
DIST: ${{ inputs.dist }}
run: |
uv run --no-sync pytest ${TEST_PATH:?} \
--tb=short -vv \
--maxfail="${MAX_FAILURES}" \
-n "${WORKERS}" \
--reruns "${RERUNS}" \
--reruns-delay 1 \
--dist=loadscope \
--durations=20 \
--cov=./litellm \
--cov-report=xml:coverage.xml \
--cov-config=pyproject.toml
if [ "${WORKERS}" = "0" ]; then
uv run --no-sync pytest ${TEST_PATH:?} \
--tb=short -vv \
--maxfail="${MAX_FAILURES}" \
--reruns "${RERUNS}" \
--reruns-delay 1 \
--durations=20 \
--cov=./litellm \
--cov-report=xml:coverage.xml \
--cov-config=pyproject.toml
else
uv run --no-sync pytest ${TEST_PATH:?} \
--tb=short -vv \
--maxfail="${MAX_FAILURES}" \
-n "${WORKERS}" \
--reruns "${RERUNS}" \
--reruns-delay 1 \
--dist="${DIST}" \
--durations=20 \
--cov=./litellm \
--cov-report=xml:coverage.xml \
--cov-config=pyproject.toml
fi
- name: Save coverage report
if: always()

View file

@ -1,190 +0,0 @@
name: _Unit Test Services Base (Reusable)
on:
workflow_call:
inputs:
test-path:
description: "Pytest path(s) to run"
required: true
type: string
workers:
description: "Number of pytest-xdist workers (0 = no parallelism)"
required: false
type: number
default: 2
reruns:
description: "Number of reruns for flaky tests"
required: false
type: number
default: 2
timeout-minutes:
description: "Job timeout in minutes"
required: false
type: number
default: 20
max-failures:
description: "Stop after this many failures"
required: false
type: number
default: 10
enable-postgres:
description: "Start a local Postgres service container and run Prisma migrations"
required: false
type: boolean
default: false
dist:
description: "pytest-xdist distribution mode (loadscope|load|worksteal|loadfile|no)"
required: false
type: string
default: "loadscope"
artifact-name:
description: "Unique name for the coverage artifact (must be unique per run)"
required: false
type: string
default: "run"
permissions:
contents: read
# The postgres service container below is spawned per-job on localhost and
# destroyed with the job. Nothing outside the runner can reach it. The
# user/password/database here are not secrets — they're bootstrap values
# for a throwaway container — so we hardcode them instead of attaching
# every matrix shard to a GHA environment just to read three "secrets"
# (which also produces a "temporarily deployed to …" notification on the
# PR timeline per shard per push).
jobs:
run:
name: Run tests
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout-minutes }}
services:
postgres:
image: postgres@sha256:705a5d5b5836f3fcba0d02c4d281e6a7dd9ed2dd4078640f08a1e1e9896e097d # postgres:14
env:
POSTGRES_USER: litellm
POSTGRES_PASSWORD: litellm
POSTGRES_DB: litellm_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: "0.10.9"
- name: Cache uv dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cache/uv
.venv
key: ${{ runner.os }}-uv-services-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-uv-services-
- name: Install dependencies
run: |
uv sync --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
- name: Generate Prisma client
env:
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
run: |
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Run Prisma migrations
if: ${{ inputs.enable-postgres }}
env:
DATABASE_URL: "postgresql://litellm:litellm@localhost:5432/litellm_test"
run: |
uv run --no-sync prisma db push --schema litellm/proxy/schema.prisma --accept-data-loss
- name: Run tests
env:
TEST_PATH: ${{ inputs.test-path }}
MAX_FAILURES: ${{ inputs.max-failures }}
WORKERS: ${{ inputs.workers }}
RERUNS: ${{ inputs.reruns }}
DIST: ${{ inputs.dist }}
DATABASE_URL: ${{ inputs.enable-postgres && 'postgresql://litellm:litellm@localhost:5432/litellm_test' || '' }}
run: |
if [ "${WORKERS}" = "0" ]; then
uv run --no-sync pytest ${TEST_PATH:?} \
--tb=short -vv \
--maxfail="${MAX_FAILURES}" \
--reruns "${RERUNS}" \
--reruns-delay 1 \
--durations=20 \
--cov=./litellm \
--cov-report=xml:coverage.xml \
--cov-config=pyproject.toml
else
uv run --no-sync pytest ${TEST_PATH:?} \
--tb=short -vv \
--maxfail="${MAX_FAILURES}" \
-n "${WORKERS}" \
--reruns "${RERUNS}" \
--reruns-delay 1 \
--dist="${DIST}" \
--durations=20 \
--cov=./litellm \
--cov-report=xml:coverage.xml \
--cov-config=pyproject.toml
fi
- name: Save coverage report
if: always()
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: coverage-${{ inputs.artifact-name }}-${{ github.run_id }}-${{ github.run_attempt }}
path: coverage.xml
retention-days: 1
upload-coverage:
name: Upload coverage to Codecov
needs: run
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Download coverage report
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
pattern: coverage-${{ inputs.artifact-name }}-${{ github.run_id }}-${{ github.run_attempt }}
path: coverage-reports
merge-multiple: true
- name: Upload to Codecov
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
with:
use_oidc: true
directory: coverage-reports
root_dir: ${{ github.workspace }}
flags: ${{ inputs.artifact-name }}
fail_ci_if_error: false

View file

@ -0,0 +1,84 @@
name: Check UI API Types Sync
on:
pull_request:
paths:
- "litellm/proxy/**"
- "litellm/types/**"
- "ui/litellm-dashboard/src/lib/http/schema.d.ts"
- "ui/litellm-dashboard/scripts/gen-api-types.mjs"
- "ui/litellm-dashboard/package.json"
- "ui/litellm-dashboard/package-lock.json"
- ".github/workflows/check-ui-api-types.yml"
permissions:
contents: read
jobs:
check-sync:
name: Verify schema.d.ts matches the proxy OpenAPI spec
runs-on: ubuntu-latest
timeout-minutes: 15
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: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: "0.10.9"
- name: Cache uv dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cache/uv
.venv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-uv-
- name: Install backend dependencies
run: uv sync --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
- name: Generate Prisma client
env:
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ui/litellm-dashboard/package-lock.json
- name: Install dashboard dependencies
working-directory: ui/litellm-dashboard
run: npm ci
- name: Regenerate types from the live spec
working-directory: ui/litellm-dashboard
env:
LITELLM_PYTHON: "uv run --no-sync python"
run: npm run gen:api
- name: Fail if types are stale
run: |
if ! git diff --exit-code -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
echo "::error file=ui/litellm-dashboard/src/lib/http/schema.d.ts::Generated API types are out of sync with the proxy OpenAPI spec."
echo ""
echo "A backend route or model changed without regenerating the dashboard types."
echo "To fix, run from ui/litellm-dashboard:"
echo " npm run gen:api"
echo "then commit the updated src/lib/http/schema.d.ts."
exit 1
fi
echo "schema.d.ts is in sync with the proxy OpenAPI spec."

View file

@ -36,3 +36,79 @@ jobs:
- name: Build
run: npm run build
frontend-lint:
runs-on: ubuntu-latest
timeout-minutes: 8
defaults:
run:
working-directory: ui/litellm-dashboard
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false
- name: Collect changed files
id: changed
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
: > "$RUNNER_TEMP/prettier_files.txt"
: > "$RUNNER_TEMP/eslint_files.txt"
while IFS= read -r f; do
[ -f "$f" ] || continue
case "$f" in
*.js | *.jsx | *.ts | *.tsx | *.mjs | *.cjs)
printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt"
printf '%s\n' "$f" >> "$RUNNER_TEMP/eslint_files.txt" ;;
*.json | *.css | *.scss | *.md | *.mdx | *.yml | *.yaml | *.html)
printf '%s\n' "$f" >> "$RUNNER_TEMP/prettier_files.txt" ;;
esac
done < <(git diff --name-only --diff-filter=ACMR --relative "$BASE_SHA"...HEAD -- .)
if [ -s "$RUNNER_TEMP/prettier_files.txt" ] || [ -s "$RUNNER_TEMP/eslint_files.txt" ]; then
echo "has_files=true" >> "$GITHUB_OUTPUT"
else
echo "has_files=false" >> "$GITHUB_OUTPUT"
echo "No lintable UI files changed in this PR; nothing to check."
fi
- name: Setup Node.js
if: steps.changed.outputs.has_files == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ui/litellm-dashboard/package-lock.json
- name: Install dependencies
if: steps.changed.outputs.has_files == 'true'
run: npm ci
- name: Lint changed files (prettier + eslint)
if: steps.changed.outputs.has_files == 'true'
run: |
prettier_files=()
eslint_files=()
while IFS= read -r f; do prettier_files+=("$f"); done < "$RUNNER_TEMP/prettier_files.txt"
while IFS= read -r f; do eslint_files+=("$f"); done < "$RUNNER_TEMP/eslint_files.txt"
status=0
if [ ${#prettier_files[@]} -gt 0 ]; then
echo "::group::Prettier (${#prettier_files[@]} files)"
npx prettier --check "${prettier_files[@]}" || { status=1; echo "::error::Unformatted files. Fix with: npm run format"; }
echo "::endgroup::"
fi
if [ ${#eslint_files[@]} -gt 0 ]; then
echo "::group::ESLint (${#eslint_files[@]} files)"
npx eslint --no-warn-ignored --pass-on-unpruned-suppressions "${eslint_files[@]}" || status=1
echo "::endgroup::"
fi
exit $status
- name: Check lint budgets
if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }}
run: |
npx eslint . -f json -o "$RUNNER_TEMP/lint-report.json" || true
node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json

View file

@ -28,6 +28,8 @@ jobs:
tests/test_litellm/completion_extras
tests/test_litellm/containers
tests/test_litellm/experimental_mcp_client
tests/test_litellm/models
tests/test_litellm/repositories
tests/test_litellm/images
tests/test_litellm/interactions
tests/test_litellm/passthrough

View file

@ -1,9 +1,10 @@
name: "Unit Tests: Proxy DB Operations"
# Uses DATABASE_URL secret — only runs on trusted branches, not PRs.
on:
push:
branches: [main, "litellm_**"]
pull_request:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
@ -30,9 +31,6 @@ concurrency:
# xdist balances its 188 parametrized cases across workers instead of
# pinning the whole file to one worker (the default --dist=loadscope
# behavior for single-file targets).
# * test_db_schema_migration.py is isolated because one test in it
# (test_aaaasschema_migration_check) takes ~170s — by itself it
# determines the shard's wall-clock floor.
jobs:
# Fast guard — fails the workflow if a test_*.py file under
# tests/proxy_unit_tests/ is not referenced by any matrix entry below.
@ -166,18 +164,6 @@ jobs:
dist: loadscope
timeout: 15
# ---- db-and-spend: isolate the 170s schema-migration test ----
# test_db_schema_migration.py has exactly one test, and that test
# is mostly waiting on `prisma migrate deploy` / `prisma migrate
# diff` subprocesses (~170s). It does no CPU-bound Python work
# inside the test. Running with workers=0 (serial, no xdist)
# skips the 4-worker cold-start cost we'd otherwise pay for a
# single test, saving ~4 minutes of wall-clock.
- test-group: schema-migration
test-path: "tests/proxy_unit_tests/test_db_schema_migration.py"
workers: 0
dist: loadscope
timeout: 15
- test-group: db-and-spend
test-path: >-
tests/proxy_unit_tests/test_prisma_client_backoff_retry.py
@ -232,12 +218,11 @@ jobs:
workers: 4
dist: loadscope
timeout: 15
uses: ./.github/workflows/_test-unit-services-base.yml
uses: ./.github/workflows/_test-unit-base.yml
with:
test-path: ${{ matrix.test-path }}
workers: ${{ matrix.workers }}
reruns: 2
timeout-minutes: ${{ matrix.timeout }}
enable-postgres: true
dist: ${{ matrix.dist }}
artifact-name: proxy-db-${{ matrix.test-group }}

View file

@ -42,6 +42,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/utils
workers: 2
reruns: 2
artifact-name: proxy-endpoints

View file

@ -1,34 +0,0 @@
name: "Unit Tests: Proxy Management-Endpoint Behavior Pinning"
on:
pull_request:
branches:
- main
- litellm_internal_staging
- litellm_oss_branch
- "litellm_**"
permissions:
contents: read
id-token: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
proxy-mgmt-behavior:
uses: ./.github/workflows/_test-unit-services-base.yml
with:
test-path: tests/proxy_behavior
# workers=0 (no xdist): the world seed is a single shared Postgres
# state — two xdist workers both call seed_world() and race on the
# ``behavior-pin-budget`` row, producing UniqueViolation + cascading
# missing-membership FK failures. The whole suite is ~7s sequentially,
# so the cost of disabling parallelism here is negligible.
workers: 0
reruns: 0
enable-postgres: true
artifact-name: proxy-mgmt-behavior
timeout-minutes: 15

View file

@ -1,28 +0,0 @@
name: "Unit Tests: Security"
# Kept push-only (was previously required by DATABASE_URL secret scoping;
# now the postgres credentials are ephemeral localhost values but the
# push-trigger stays to match the proxy-db workflow cadence).
on:
push:
branches: [main, "litellm_**"]
permissions:
contents: read
id-token: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
security:
uses: ./.github/workflows/_test-unit-services-base.yml
with:
test-path: "tests/proxy_security_tests/"
workers: 1
reruns: 2
timeout-minutes: 20
enable-postgres: true
artifact-name: security

View file

@ -240,6 +240,24 @@ graph LR
7. `DBSpendUpdateWriter.update_database()` queues spend increments to Redis
8. Background job `update_spend` flushes queued spend to PostgreSQL every 60s
### Data Access Layer (Models & Repositories)
Database entities and the operations on them live in two packages at the root of `litellm/` so both the gateway (`proxy/`) and the SDK can use them without importing proxy internals:
- `litellm/models/` holds the canonical Pydantic definitions for every persisted entity (`LiteLLM_VerificationToken`, `LiteLLM_TeamTable`, `LiteLLM_UserTable`, etc.). `proxy/_types.py` re-exports these for backwards compatibility, so existing imports keep working.
- `litellm/repositories/` holds the data-access layer. `BaseRepository[T]` provides the generic CRUD (`find_by_id`, `find_many`, `create`, `update`, `delete`, `count`, `exists`); entity repositories such as `VerificationTokenRepository`, `TeamRepository`, and `UserRepository` add domain-specific queries and writes on top of it.
Conventions to follow when touching this layer:
| Concern | How it's handled |
|---------|------------------|
| JSON columns | Prisma `Json` columns are stored as JSON strings. Repositories `json.dumps()` on write and `json.loads()` on read (see `_to_model` and the `_build_*_data` helpers). |
| Archive-then-delete | `delete_team` / `delete_token` copy the row into the `LiteLLM_Deleted*` table and delete the original inside a single `prisma_client.db.tx()` transaction. Archive payloads are built explicitly so only columns that exist on the archive table are written. |
| Column vs. field names | Where a model field differs from its DB column (for example `org_id` maps to the `organization_id` column), the repository translates in both directions rather than relying on Pydantic to guess. |
| Array mutations | Adds use Prisma's atomic `push` (`add_member`, `add_admin`, `add_models`) to avoid read-modify-write races. Removals fall back to read-modify-write because Prisma has no atomic array remove. |
To add a new entity, define the model under `litellm/models/`, re-export it from `proxy/_types.py` if existing code imports it from there, and add a repository under `litellm/repositories/` (subclass `BaseRepository` for plain CRUD, or add bespoke methods when the entity needs encryption, archiving, or atomic array updates). Mirror the tests in `tests/test_litellm/repositories/`.
---
## 2. SDK Request Flow

View file

@ -42,7 +42,7 @@ When you must use real LLM models to, for example, write e2e tests, write a QA r
If you're an internal contributor, when creating a new PR, the typical flow is to branch off litellm_internal_staging and create a branch prefixed with litellm_. Do not create a branch prefixed with claude/ and generally do not have / in your branch names
Do not add `Co-Authored-By: Claude` or any Claude attribution to commit messages. Never use a `claude/` prefix or put a `/` in a branch name. Do not add "Generated with Claude Code" (or any similar attribution) to PR descriptions. Do not create a new PR/branch off the existing PR to fix/add something that is related and could've just been committed directly to the existing PR's branch
Do not add `Co-Authored-By: Claude` or any Claude attribution to commit messages. Never use a `claude/` prefix or put a `/` in a branch name. Do not add "Generated with Claude Code" (or any similar attribution) to PR descriptions or comments. Do not create a new PR/branch off the existing PR to fix/add something that is related and could've just been committed directly to the existing PR's branch
When working on a PR, keep the PR description in sync with new commits being made

View file

@ -407,7 +407,7 @@ Support for more providers. Missing a provider or LLM Platform, raise a [feature
### Run in Developer Mode
#### Services
1. Setup .env file in root
2. Run dependant services `docker-compose up db prometheus`
2. Run dependent services `docker-compose up db prometheus`
#### Backend
1. (In root) create virtual environment `python -m venv .venv`

View file

@ -12,9 +12,14 @@ spec:
{{- include "litellm.backend.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.backend.podAnnotations }}
{{- if or .Values.gateway.config.create .Values.backend.podAnnotations }}
annotations:
{{- if .Values.gateway.config.create }}
checksum/config: {{ include (print $.Template.BasePath "/gateway/configmap.yaml") . | sha256sum }}
{{- end }}
{{- with .Values.backend.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
labels:
{{- include "litellm.backend.selectorLabels" . | nindent 8 }}
@ -35,7 +40,17 @@ spec:
protocol: TCP
env:
{{- include "litellm.serverEnv" (dict "root" $ "component" .Values.backend) | nindent 12 }}
{{- if .Values.gateway.config.create }}
- name: CONFIG_FILE_PATH
value: /app/config/config.yaml
{{- end }}
{{- include "litellm.envFrom" .Values.backend | nindent 10 }}
{{- if .Values.gateway.config.create }}
volumeMounts:
- name: gateway-config
mountPath: /app/config/config.yaml
subPath: config.yaml
{{- end }}
{{- with .Values.backend.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
@ -46,6 +61,12 @@ spec:
{{- end }}
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}
{{- if .Values.gateway.config.create }}
volumes:
- name: gateway-config
configMap:
name: {{ include "litellm.gateway.fullname" . }}-config
{{- end }}
{{- with .Values.backend.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}

View file

@ -0,0 +1,23 @@
-- AlterTable: add admin-configured env_vars to MCP server table
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "env_vars" JSONB DEFAULT '[]';
-- CreateTable: per-user env var values for MCP servers
CREATE TABLE IF NOT EXISTS "LiteLLM_MCPUserEnvVars" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"values_b64" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LiteLLM_MCPUserEnvVars_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "LiteLLM_MCPUserEnvVars_user_id_server_id_key" ON "LiteLLM_MCPUserEnvVars"("user_id", "server_id");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_MCPUserEnvVars_user_id_idx" ON "LiteLLM_MCPUserEnvVars"("user_id");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_MCPUserEnvVars_server_id_idx" ON "LiteLLM_MCPUserEnvVars"("server_id");

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "oauth2_flow" TEXT;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "timeout" DOUBLE PRECISION;

View file

@ -311,6 +311,11 @@ model LiteLLM_MCPServerTable {
tool_name_to_description Json? @default("{}")
extra_headers String[] @default([])
static_headers Json? @default("{}")
// Admin-configured environment variables interpolated into static_headers
// via ${NAME} syntax. Stored as an array of
// {name, value, scope, description}. scope is "global" (value used as-is)
// or "user" (value supplied per-user via LiteLLM_MCPUserEnvVars).
env_vars Json? @default("[]")
// Health check status
status String? @default("unknown")
last_health_check DateTime?
@ -322,6 +327,7 @@ model LiteLLM_MCPServerTable {
authorization_url String?
token_url String?
registration_url String?
oauth2_flow String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(true)
delegate_auth_to_upstream Boolean @default(false)
@ -330,6 +336,7 @@ model LiteLLM_MCPServerTable {
byok_description String[] @default([])
byok_api_key_help_url String?
source_url String?
timeout Float?
// BYOM submission lifecycle
approval_status String? @default("active")
submitted_by String?
@ -364,6 +371,21 @@ model LiteLLM_MCPUserCredentials {
@@unique([user_id, server_id])
}
// Per-user environment variable values for MCP servers.
// values_b64 is an encrypted JSON object: {VAR_NAME: "value", ...}.
model LiteLLM_MCPUserEnvVars {
id String @id @default(uuid())
user_id String
server_id String
values_b64 String
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
@@unique([user_id, server_id])
@@index([user_id])
@@index([server_id])
}
// Generate Tokens for Proxy
model LiteLLM_VerificationToken {
token String @id

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-proxy-extras"
version = "0.4.73"
version = "0.4.74"
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.73"
version = "0.4.74"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-proxy-extras==",

View file

@ -16,8 +16,17 @@ import os
# Load .env before any other litellm imports so env vars (e.g. LITELLM_UI_SESSION_DURATION) are available
import dotenv as _dotenv
def _dev_env_hot_reload_enabled() -> bool:
"""The proxy exports this flag when started with ``--reload``. A reloaded
worker is a fresh process that inherits the reloader's environment, so an
edited ``.env`` value stays masked by the stale inherited one unless we
let the file win; overriding makes the edit take effect on reload."""
return os.getenv("LITELLM_DEV_ENV_HOT_RELOAD") == "True"
if os.getenv("LITELLM_MODE", "DEV") == "DEV":
_dotenv.load_dotenv()
_dotenv.load_dotenv(override=_dev_env_hot_reload_enabled())
from typing import (
Callable,
@ -278,6 +287,7 @@ ovhcloud_key: Optional[str] = None
lemonade_key: Optional[str] = None
sap_service_key: Optional[str] = None
amazon_nova_api_key: Optional[str] = None
inception_key: Optional[str] = None
common_cloud_provider_auth_params: dict = {
"params": ["project", "region_name", "token"],
"providers": ["vertex_ai", "bedrock", "watsonx", "azure", "vertex_ai_beta"],
@ -432,6 +442,13 @@ custom_prometheus_metadata_labels: List[str] = []
custom_prometheus_tags: List[str] = []
prometheus_metrics_config: Optional[List] = None
prometheus_emit_stream_label: bool = False
# Opt-in: emit `rate_limit_category` and `rate_limit_type` labels on
# `litellm_proxy_failed_requests_metric`. Off by default to preserve the
# pre-unification label set so existing dashboards / recording rules keyed on
# that metric keep matching after upgrade. Enable when downstream consumers
# are ready to split 429s by source (vendor vs. litellm) and dimension
# (RPM/TPM/concurrent/budget).
prometheus_emit_rate_limit_labels: bool = False
prometheus_user_budget_label_include_email_alias: bool = False
prometheus_end_user_metrics_max_series_per_metric: Optional[int] = 10000
prometheus_end_user_metrics_ttl_seconds: Optional[float] = 3600.0
@ -443,6 +460,7 @@ disable_copilot_system_to_assistant: bool = (
False # If false (default), converts all 'system' role messages to 'assistant' for GitHub Copilot compatibility. Set to true to disable this behavior.
)
public_mcp_servers: Optional[List[str]] = None
public_mcp_hub_strict_whitelist: bool = True
public_model_groups: Optional[List[str]] = None
public_agent_groups: Optional[List[str]] = None
# Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]])
@ -551,6 +569,7 @@ cohere_models: Set = set()
cohere_chat_models: Set = set()
mistral_chat_models: Set = set()
text_completion_codestral_models: Set = set()
text_completion_inception_models: Set = set()
anthropic_models: Set = set()
openrouter_models: Set = set()
datarobot_models: Set = set()
@ -609,6 +628,7 @@ cerebras_models: Set = set()
galadriel_models: Set = set()
nvidia_nim_models: Set = set()
nvidia_riva_models: Set = set()
soniox_models: Set = set()
sambanova_models: Set = set()
sambanova_embedding_models: Set = set()
novita_models: Set = set()
@ -628,6 +648,7 @@ publicai_models: Set = set()
v0_models: Set = set()
morph_models: Set = set()
lambda_ai_models: Set = set()
inception_models: Set = set()
hyperbolic_models: Set = set()
black_forest_labs_models: Set = set()
recraft_models: Set = set()
@ -792,6 +813,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None):
fireworks_ai_embedding_models.add(key)
elif value.get("litellm_provider") == "text-completion-codestral":
text_completion_codestral_models.add(key)
elif value.get("litellm_provider") == "text-completion-inception":
text_completion_inception_models.add(key)
elif value.get("litellm_provider") == "xai":
xai_models.add(key)
elif value.get("litellm_provider") == "zai":
@ -838,6 +861,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None):
nvidia_nim_models.add(key)
elif value.get("litellm_provider") == "nvidia_riva":
nvidia_riva_models.add(key)
elif value.get("litellm_provider") == "soniox":
soniox_models.add(key)
elif value.get("litellm_provider") == "sambanova":
sambanova_models.add(key)
elif value.get("litellm_provider") == "sambanova-embedding-models":
@ -878,6 +903,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None):
morph_models.add(key)
elif value.get("litellm_provider") == "lambda_ai":
lambda_ai_models.add(key)
elif value.get("litellm_provider") == "inception":
inception_models.add(key)
elif value.get("litellm_provider") == "hyperbolic":
hyperbolic_models.add(key)
elif value.get("litellm_provider") == "black_forest_labs":
@ -980,6 +1007,7 @@ model_list = list(
| watsonx_models
| gemini_models
| text_completion_codestral_models
| text_completion_inception_models
| xai_models
| zai_models
| fal_ai_models
@ -1000,6 +1028,7 @@ model_list = list(
| galadriel_models
| nvidia_nim_models
| nvidia_riva_models
| soniox_models
| sambanova_models
| azure_text_models
| novita_models
@ -1018,6 +1047,7 @@ model_list = list(
| v0_models
| morph_models
| lambda_ai_models
| inception_models
| black_forest_labs_models
| recraft_models
| cometapi_models
@ -1074,6 +1104,7 @@ models_by_provider: dict = {
"fireworks_ai": fireworks_ai_models | fireworks_ai_embedding_models,
"aleph_alpha": aleph_alpha_models,
"text-completion-codestral": text_completion_codestral_models,
"text-completion-inception": text_completion_inception_models,
"xai": xai_models,
"zai": zai_models,
"fal_ai": fal_ai_models,
@ -1098,6 +1129,7 @@ models_by_provider: dict = {
"galadriel": galadriel_models,
"nvidia_nim": nvidia_nim_models,
"nvidia_riva": nvidia_riva_models,
"soniox": soniox_models,
"sambanova": sambanova_models | sambanova_embedding_models,
"novita": novita_models,
"nebius": nebius_models | nebius_embedding_models,
@ -1118,6 +1150,7 @@ models_by_provider: dict = {
"v0": v0_models,
"morph": morph_models,
"lambda_ai": lambda_ai_models,
"inception": inception_models,
"hyperbolic": hyperbolic_models,
"black_forest_labs": black_forest_labs_models,
"recraft": recraft_models,
@ -1277,6 +1310,8 @@ from .exceptions import (
NotFoundError,
PermissionDeniedError,
RateLimitError,
RateLimitErrorCategory,
RateLimitType,
ServiceUnavailableError,
BadGatewayError,
OpenAIError,
@ -1728,6 +1763,9 @@ if TYPE_CHECKING:
from .llms.openrouter.responses.transformation import (
OpenRouterResponsesAPIConfig as OpenRouterResponsesAPIConfig,
)
from .llms.bedrock_mantle.responses.transformation import (
BedrockMantleResponsesAPIConfig as BedrockMantleResponsesAPIConfig,
)
from .llms.gemini.interactions.transformation import (
GoogleAIStudioInteractionsConfig as GoogleAIStudioInteractionsConfig,
)
@ -1869,6 +1907,9 @@ if TYPE_CHECKING:
from .llms.codestral.completion.transformation import (
CodestralTextCompletionConfig as CodestralTextCompletionConfig,
)
from .llms.inception.completion.transformation import (
InceptionTextCompletionConfig as InceptionTextCompletionConfig,
)
from .llms.azure.azure import (
AzureOpenAIAssistantsAPIConfig as AzureOpenAIAssistantsAPIConfig,
)
@ -1937,6 +1978,9 @@ if TYPE_CHECKING:
from .llms.lambda_ai.chat.transformation import (
LambdaAIChatConfig as LambdaAIChatConfig,
)
from .llms.inception.chat.transformation import (
InceptionChatConfig as InceptionChatConfig,
)
from .llms.hyperbolic.chat.transformation import (
HyperbolicChatConfig as HyperbolicChatConfig,
)

View file

@ -237,6 +237,7 @@ LLM_CONFIG_NAMES = (
"PerplexityResponsesConfig",
"DatabricksResponsesAPIConfig",
"OpenRouterResponsesAPIConfig",
"BedrockMantleResponsesAPIConfig",
"GoogleAIStudioInteractionsConfig",
"OpenAIOSeriesConfig",
"AnthropicSkillsConfig",
@ -267,6 +268,7 @@ LLM_CONFIG_NAMES = (
"AIMLChatConfig",
"VolcEngineChatConfig",
"CodestralTextCompletionConfig",
"InceptionTextCompletionConfig",
"AzureOpenAIAssistantsAPIConfig",
"HerokuChatConfig",
"CometAPIConfig",
@ -310,6 +312,7 @@ LLM_CONFIG_NAMES = (
"MorphChatConfig",
"RAGFlowConfig",
"LambdaAIChatConfig",
"InceptionChatConfig",
"HyperbolicChatConfig",
"VercelAIGatewayConfig",
"OVHCloudChatConfig",
@ -318,6 +321,7 @@ LLM_CONFIG_NAMES = (
"LemonadeChatConfig",
"SnowflakeEmbeddingConfig",
"AmazonNovaChatConfig",
"SonioxAudioTranscriptionConfig",
)
# Types that support lazy loading via _lazy_import_types
@ -956,6 +960,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
".llms.openrouter.responses.transformation",
"OpenRouterResponsesAPIConfig",
),
"BedrockMantleResponsesAPIConfig": (
".llms.bedrock_mantle.responses.transformation",
"BedrockMantleResponsesAPIConfig",
),
"GoogleAIStudioInteractionsConfig": (
".llms.gemini.interactions.transformation",
"GoogleAIStudioInteractionsConfig",
@ -1040,6 +1048,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
".llms.codestral.completion.transformation",
"CodestralTextCompletionConfig",
),
"InceptionTextCompletionConfig": (
".llms.inception.completion.transformation",
"InceptionTextCompletionConfig",
),
"AzureOpenAIAssistantsAPIConfig": (
".llms.azure.azure",
"AzureOpenAIAssistantsAPIConfig",
@ -1154,6 +1166,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
"MorphChatConfig": (".llms.morph.chat.transformation", "MorphChatConfig"),
"RAGFlowConfig": (".llms.ragflow.chat.transformation", "RAGFlowConfig"),
"LambdaAIChatConfig": (".llms.lambda_ai.chat.transformation", "LambdaAIChatConfig"),
"InceptionChatConfig": (
".llms.inception.chat.transformation",
"InceptionChatConfig",
),
"HyperbolicChatConfig": (
".llms.hyperbolic.chat.transformation",
"HyperbolicChatConfig",
@ -1180,6 +1196,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
".llms.amazon_nova.chat.transformation",
"AmazonNovaChatConfig",
),
"SonioxAudioTranscriptionConfig": (
".llms.soniox.audio_transcription.transformation",
"SonioxAudioTranscriptionConfig",
),
}
# Import map for utils module lazy imports

View file

@ -371,6 +371,8 @@ class ServiceLogging(CustomLogger):
service=ServiceTypes.LITELLM,
duration=_duration,
call_type=kwargs.get("call_type", "unknown"),
start_time=start_time,
end_time=end_time,
)
except Exception as e:
raise e

View file

@ -159,7 +159,9 @@ async def _send_message_via_completion_bridge(
api_base=api_base,
)
return LiteLLMSendMessageResponse.from_dict(response_dict)
return LiteLLMSendMessageResponse.from_dict(
response_dict, request_id=str(request.id)
)
async def _execute_a2a_send_with_retry(
@ -317,15 +319,6 @@ async def asend_message(
)
card_url = getattr(agent_card, "url", None) if agent_card else None
context_id = trace_id or str(uuid.uuid4())
message = request.params.message
if isinstance(message, dict):
if message.get("context_id") is None:
message["context_id"] = context_id
else:
if getattr(message, "context_id", None) is None:
message.context_id = context_id
a2a_response = await _execute_a2a_send_with_retry(
a2a_client=a2a_client,
request=request,
@ -338,7 +331,9 @@ async def asend_message(
verbose_logger.info(f"A2A send_message completed, request_id={request.id}")
# Wrap in LiteLLM response type for _hidden_params support
response = LiteLLMSendMessageResponse.from_a2a_response(a2a_response)
response = LiteLLMSendMessageResponse.from_a2a_response(
a2a_response, request_id=str(request.id)
)
# Calculate token usage from request and response
response_dict = a2a_response.model_dump(mode="json", exclude_none=True)

View file

@ -182,6 +182,14 @@ class ResponsesToCompletionBridgeHandler:
client=kwargs.get("client"),
)
# Pin the resolved provider so `responses()` doesn't re-run
# `get_llm_provider()` on the model string and strip a second
# provider prefix (see GitHub issue #28505). request_data already
# carries `custom_llm_provider` via the spread of
# `sanitized_litellm_params`; overwriting it on the dict (rather
# than adding an explicit kwarg) avoids the duplicate-keyword
# TypeError that would otherwise fire on the real bridge path.
request_data["custom_llm_provider"] = custom_llm_provider
result = responses(
**request_data,
)
@ -268,6 +276,13 @@ class ResponsesToCompletionBridgeHandler:
except Exception as e:
raise e
# Pin the resolved provider so `aresponses()` doesn't re-run
# `get_llm_provider()` on the model string and strip a second
# provider prefix (see GitHub issue #28505). Set on request_data
# rather than passed as a separate kwarg to avoid the duplicate-
# keyword TypeError when `sanitized_litellm_params` already
# carries `custom_llm_provider`.
request_data["custom_llm_provider"] = custom_llm_provider
result = await aresponses(
**request_data,
aresponses=True,

View file

@ -402,6 +402,20 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
instructions,
) = self.convert_chat_completion_messages_to_responses_api(messages)
# OpenAI's Responses API rejects an empty input. For a system-only
# request, carry the system message as a system-role input item instead
# of instructions, mirroring how non-string system content is already
# handled in convert_chat_completion_messages_to_responses_api.
if not input_items and instructions is not None:
input_items = [
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": instructions}],
}
]
instructions = None
optional_params = self._extract_extra_body_params(optional_params)
# Build responses API request using the reverse transformation logic

View file

@ -585,6 +585,7 @@ LITELLM_CHAT_PROVIDERS = [
"volcengine",
"codestral",
"text-completion-codestral",
"text-completion-inception",
"deepseek",
"sambanova",
"maritalk",
@ -620,6 +621,7 @@ LITELLM_CHAT_PROVIDERS = [
"oci",
"morph",
"lambda_ai",
"inception",
"vercel_ai_gateway",
"wandb",
"ovhcloud",
@ -676,6 +678,7 @@ OPENAI_CHAT_COMPLETION_PARAMS = [
"extra_headers",
"thinking",
"web_search_options",
"include_server_side_tool_invocations",
"service_tier",
"prompt_cache_key",
"prompt_cache_retention",
@ -737,6 +740,7 @@ DEFAULT_CHAT_COMPLETION_PARAM_VALUES = {
"verbosity": None,
"thinking": None,
"web_search_options": None,
"include_server_side_tool_invocations": None,
"service_tier": None,
"safety_identifier": None,
"prompt_cache_key": None,
@ -779,6 +783,7 @@ openai_compatible_endpoints: List = [
"https://api.v0.dev/v1",
"https://api.morphllm.com/v1",
"https://api.lambda.ai/v1",
"https://api.inceptionlabs.ai/v1",
"https://api.hyperbolic.xyz/v1",
"https://ai-gateway.helicone.ai/",
"https://ai-gateway.vercel.sh/v1",
@ -835,6 +840,7 @@ openai_compatible_providers: List = [
"helicone",
"morph",
"lambda_ai",
"inception",
"hyperbolic",
"vercel_ai_gateway",
"aiml",

View file

@ -2425,12 +2425,11 @@ class BaseTokenUsageProcessor:
if not attr.startswith("_") and not callable(
getattr(usage.completion_tokens_details, attr)
):
current_val = getattr(
combined.completion_tokens_details, attr, 0
current_val = (
getattr(combined.completion_tokens_details, attr, 0) or 0
)
new_val = getattr(usage.completion_tokens_details, attr, 0)
if new_val is not None and current_val is not None:
new_val = getattr(usage.completion_tokens_details, attr, 0) or 0
if isinstance(new_val, (int, float)):
setattr(
combined.completion_tokens_details,
attr,

View file

@ -9,13 +9,109 @@
## LiteLLM versions of the OpenAI Exception Types
from typing import Any, Dict, Optional
import enum
from typing import Any, Dict, Optional, Union
import httpx
import openai
from litellm.types.utils import LiteLLMCommonStrings
class RateLimitErrorCategory(str, enum.Enum):
"""
Category of a rate limit error, allowing callers to distinguish where the rate
limit originated. Exposed on every :class:`RateLimitError` instance via the
``category`` attribute.
Use these values to switch on the rate limit source, e.g.::
try:
...
except litellm.RateLimitError as e:
if e.category == RateLimitErrorCategory.LITELLM_RATE_LIMIT:
... # litellm's own limiter (key/team/user/model RPM/TPM/budget)
elif e.category == RateLimitErrorCategory.VENDOR_RATE_LIMIT:
... # the upstream LLM provider returned 429
"""
VENDOR_RATE_LIMIT = "vendor_rate_limit"
"""The upstream LLM provider returned a rate-limit response (e.g. OpenAI 429)."""
VENDOR_BATCH_RATE_LIMIT = "vendor_batch_rate_limit"
"""The upstream LLM provider returned a rate-limit response on a batch endpoint."""
LITELLM_RATE_LIMIT = "litellm_rate_limit"
"""LiteLLM's own rate limiter (key/team/user/model RPM/TPM, budget, parallel-requests, etc.) blocked the request."""
LITELLM_BATCH_RATE_LIMIT = "litellm_batch_rate_limit"
"""LiteLLM's own batch rate limiter (token/request budget across a batch input file) blocked the request."""
class RateLimitType(str, enum.Enum):
"""
The dimension that was exceeded when a rate-limit error fired.
This is orthogonal to :class:`RateLimitErrorCategory` *category* tells
callers **who** rate-limited the request (the upstream vendor vs. one of
litellm's own limiters), while *type* tells them **which limit dimension**
was exceeded (an RPM ceiling, a TPM ceiling, a max-parallel-requests
ceiling, a budget cap, or a max-iterations cap).
Surfaced both on every :class:`RateLimitError` instance via the
``rate_limit_type`` attribute and on the structured
``StandardLoggingPayload.error_information.error_rate_limit_type`` field
so custom callbacks / metrics consumers can split rate-limit failures by
cause without parsing free-text error messages.
"""
REQUESTS = "requests"
"""Requests-per-minute (RPM) or requests-per-window ceiling exceeded."""
TOKENS = "tokens"
"""Tokens-per-minute (TPM) or tokens-per-window ceiling exceeded."""
CONCURRENT_REQUESTS = "concurrent_requests"
"""``max_parallel_requests`` — too many in-flight requests at once."""
BUDGET = "budget"
"""Spend budget cap reached (key, team, user, or per-session)."""
MAX_ITERATIONS = "max_iterations"
"""Per-session max-iterations cap reached (agent-style flows)."""
_RATE_LIMIT_CATEGORY_VALUES = frozenset(c.value for c in RateLimitErrorCategory)
_RATE_LIMIT_TYPE_VALUES = frozenset(t.value for t in RateLimitType)
def validate_rate_limit_category(value: Any) -> Optional[str]:
"""Return ``value`` only if it matches a known :class:`RateLimitErrorCategory`.
Used at duck-typed read sites (StandardLoggingPayload extraction, Prometheus
labels) to reject `.category` strings set by unrelated third-party exceptions
otherwise those would leak into custom-callback payloads and Prometheus
label cardinality.
"""
if isinstance(value, RateLimitErrorCategory):
return value.value
if isinstance(value, str) and value in _RATE_LIMIT_CATEGORY_VALUES:
return value
return None
def validate_rate_limit_type(value: Any) -> Optional[str]:
"""Return ``value`` only if it matches a known :class:`RateLimitType`.
See :func:`validate_rate_limit_category` for the rationale.
"""
if isinstance(value, RateLimitType):
return value.value
if isinstance(value, str) and value in _RATE_LIMIT_TYPE_VALUES:
return value
return None
_MINIMAL_ERROR_RESPONSE: Optional[httpx.Response] = None
@ -321,6 +417,18 @@ class PermissionDeniedError(openai.PermissionDeniedError): # type: ignore
class RateLimitError(openai.RateLimitError): # type: ignore
"""
Unified rate-limit error.
Every rate-limit condition surfaced by litellm whether it originated from
an upstream LLM provider, a vendor batch endpoint, or one of litellm's own
proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,
max-iterations, etc.) is raised as an instance of this class.
The :attr:`category` attribute lets callers distinguish the source. See
:class:`RateLimitErrorCategory` for the available values.
"""
def __init__(
self,
message,
@ -330,6 +438,12 @@ class RateLimitError(openai.RateLimitError): # type: ignore
litellm_debug_info: Optional[str] = None,
max_retries: Optional[int] = None,
num_retries: Optional[int] = None,
category: Union[str, RateLimitErrorCategory] = (
RateLimitErrorCategory.VENDOR_RATE_LIMIT
),
rate_limit_type: Optional[Union[str, RateLimitType]] = None,
headers: Optional[Dict[str, str]] = None,
detail: Any = None,
):
self.status_code = 429
self.message = "litellm.RateLimitError: {}".format(message)
@ -338,9 +452,39 @@ class RateLimitError(openai.RateLimitError): # type: ignore
self.litellm_debug_info = litellm_debug_info
self.max_retries = max_retries
self.num_retries = num_retries
self.category = (
category.value if isinstance(category, RateLimitErrorCategory) else category
)
# Which dimension was exceeded — request count, token count, parallel
# requests, budget, max iterations. None when the source didn't
# classify the failure (e.g. legacy vendor 429 with no header hints).
self.rate_limit_type: Optional[str] = (
rate_limit_type.value
if isinstance(rate_limit_type, RateLimitType)
else rate_limit_type
)
# Headers explicitly attached to the error (e.g. retry-after,
# rate_limit_type, reset_at). Preserved across the proxy boundary so
# clients can react appropriately.
#
# IMPORTANT: we deliberately do NOT auto-populate self.headers from
# response.headers when only `response` is provided. A vendor 429 can
# set arbitrary response headers (Set-Cookie, CORS overrides, …); if
# those leaked into e.headers and a downstream proxy serializer
# forwarded them to the client, a malicious upstream could inject
# browser-interpreted headers for the proxy origin. Vendor response
# headers stay reachable on `e.response.headers` for callers that
# explicitly want them; only the proxy-supplied `headers=` kwarg
# makes it onto `self.headers`.
_response_headers = (
getattr(response, "headers", None) if response is not None else None
)
self.headers: Optional[Dict[str, str]] = (
{k: str(v) for k, v in headers.items()} if headers else None
)
# Mirrors FastAPI HTTPException.detail so the same instance can be
# serialized through both the ProxyException and HTTPException paths.
self.detail = detail if detail is not None else self.message
self.response = httpx.Response(
status_code=429,
headers=_response_headers,
@ -843,11 +987,24 @@ LITELLM_EXCEPTION_TYPES = [
class BudgetExceededError(Exception):
def __init__(
self, current_cost: float, max_budget: float, message: Optional[str] = None
self,
current_cost: float,
max_budget: float,
message: Optional[str] = None,
llm_provider: Optional[str] = None,
):
self.current_cost = current_cost
self.max_budget = max_budget
self.status_code = 429
self.llm_provider = llm_provider or ""
# Surface unified rate-limit fields without joining the RateLimitError
# hierarchy so existing `except BudgetExceededError:` handlers keep
# working; custom callbacks reading StandardLoggingPayload pick these
# up via the same `category` / `rate_limit_type` attributes the rest
# of the unified rate-limit error path uses. Stored as plain strings
# to match the normalization RateLimitError.__init__ performs.
self.category: str = RateLimitErrorCategory.LITELLM_RATE_LIMIT.value
self.rate_limit_type: str = RateLimitType.BUDGET.value
message = (
message
or f"Budget has been exceeded! Current cost: {current_cost}, Max budget: {max_budget}"
@ -1062,3 +1219,37 @@ class GuardrailInterventionNormalStringError(
def __repr__(self):
return self.__str__()
class SensitiveDataRouteException(Exception):
"""
Exception raised when a guardrail detects sensitive data and wants to reroute the request.
Instead of blocking the request, this exception signals that the request should be
routed to a different model (typically an on-premise model for data privacy).
The proxy catches this exception and:
1. Reroutes the current request to the specified model
2. When sticky_session_routing is True, stores the routing decision in session
cache so all subsequent requests in the same session are routed to the same model
"""
def __init__(
self,
route_to_model: str,
session_id: str,
guardrail_name: Optional[str] = None,
detection_info: Optional[Dict[str, Any]] = None,
message: Optional[str] = None,
sticky_session_routing: bool = True,
):
self.route_to_model = route_to_model
self.session_id = session_id
self.guardrail_name = guardrail_name
self.detection_info = detection_info or {}
self.sticky_session_routing = sticky_session_routing
self.message = (
message
or f"Sensitive data detected by {guardrail_name}. Routing to model: {route_to_model}"
)
super().__init__(self.message)

View file

@ -4,6 +4,7 @@ LiteLLM Proxy uses this MCP Client to connnect to other MCP servers.
import asyncio
import base64
import os
from typing import (
Any,
Awaitable,
@ -16,7 +17,6 @@ from typing import (
TypeVar,
Union,
)
import httpx
from mcp import ClientSession, ReadResourceResult, Resource, StdioServerParameters
from mcp.client.sse import sse_client
@ -42,9 +42,8 @@ from mcp.types import (
)
from mcp.types import Tool as MCPTool
from pydantic import AnyUrl
from litellm._logging import verbose_logger
from litellm.constants import MCP_CLIENT_TIMEOUT
from litellm.constants import MCP_CLIENT_TIMEOUT, MCP_NPM_CACHE_DIR
from litellm.llms.custom_httpx.http_handler import get_ssl_configuration
from litellm.types.llms.custom_http import VerifyTypes
from litellm.types.mcp import (
@ -61,13 +60,33 @@ def to_basic_auth(auth_value: str) -> str:
return base64.b64encode(auth_value.encode("utf-8")).decode()
def _strip_header_whitespace(headers: Dict[str, str]) -> Dict[str, str]:
return {
(key.strip() if isinstance(key, str) else key): (
value.strip() if isinstance(value, str) else value
)
for key, value in headers.items()
}
def _first_non_cancelled_cause(exc: BaseException) -> Optional[BaseException]:
queue: List[BaseException] = [exc]
while queue:
current = queue.pop(0)
nested = getattr(current, "exceptions", None)
if nested:
queue.extend(nested)
elif not isinstance(current, asyncio.CancelledError):
return current
return None
TSessionResult = TypeVar("TSessionResult")
class MCPSigV4Auth(httpx.Auth):
"""
httpx Auth class that signs each request with AWS SigV4.
This is used for MCP servers that require AWS SigV4 authentication,
such as AWS Bedrock AgentCore MCP servers. httpx calls auth_flow()
for every outgoing request, enabling per-request signature computation.
@ -92,10 +111,8 @@ class MCPSigV4Auth(httpx.Auth):
"Missing botocore to use AWS SigV4 authentication. "
"Run 'pip install boto3'."
)
self.service_name = aws_service_name or "bedrock-agentcore"
self.region_name = aws_region_name or "us-east-1"
# Note: os.environ/ prefixed values are already resolved by
# ProxyConfig._check_for_os_environ_vars() at config load time.
# Values arrive here as plain strings.
@ -143,20 +160,17 @@ class MCPSigV4Auth(httpx.Auth):
session_name = (
aws_session_name or f"litellm-mcp-{int(__import__('time').time())}"
)
sts_kwargs: dict = {"region_name": aws_region_name}
if aws_access_key_id and aws_secret_access_key:
sts_kwargs["aws_access_key_id"] = aws_access_key_id
sts_kwargs["aws_secret_access_key"] = aws_secret_access_key
if aws_session_token:
sts_kwargs["aws_session_token"] = aws_session_token
sts_client = boto3.client("sts", **sts_kwargs)
sts_response = sts_client.assume_role(
RoleArn=aws_role_name,
RoleSessionName=session_name,
)
sts_creds = sts_response["Credentials"]
return Credentials(
access_key=sts_creds["AccessKeyId"],
@ -178,17 +192,14 @@ class MCPSigV4Auth(httpx.Auth):
data=request.content,
headers=dict(request.headers),
)
# Sign the request — SigV4Auth.add_auth() adds Authorization,
# X-Amz-Date, and X-Amz-Security-Token (if session token present).
# Host header is derived automatically from the URL.
sigv4 = SigV4Auth(self.credentials, self.service_name, self.region_name)
sigv4.add_auth(aws_request)
# Copy SigV4 headers back to the httpx request
for header_name, header_value in aws_request.headers.items():
request.headers[header_name] = header_value
yield request
@ -198,6 +209,8 @@ class MCPClient:
SSE and HTTP transports
Authentication via Bearer token, Basic Auth, or API Key
Tool calling with error handling and result parsing
Sampling callbacks for upstream server LLM requests
Elicitation callbacks for upstream server user-input requests
"""
def __init__(
@ -211,6 +224,9 @@ class MCPClient:
extra_headers: Optional[Dict[str, str]] = None,
ssl_verify: Optional[VerifyTypes] = None,
aws_auth: Optional[httpx.Auth] = None,
sampling_callback: Optional[Callable] = None,
elicitation_callback: Optional[Callable] = None,
logging_callback: Optional[Callable] = None,
):
self.server_url: str = server_url
self.transport_type: MCPTransport = transport_type
@ -222,6 +238,9 @@ class MCPClient:
self.ssl_verify: Optional[VerifyTypes] = ssl_verify
self._aws_auth: Optional[httpx.Auth] = aws_auth
self._last_initialize_instructions: Optional[str] = None
self._sampling_callback: Optional[Callable] = sampling_callback
self._elicitation_callback: Optional[Callable] = elicitation_callback
self._logging_callback: Optional[Callable] = logging_callback
# handle the basic auth value if provided
if auth_value:
self.update_auth_value(auth_value)
@ -231,23 +250,20 @@ class MCPClient:
) -> Tuple[Any, Optional[httpx.AsyncClient]]:
"""
Create the appropriate transport context based on transport type.
Returns:
Tuple of (transport_context, http_client).
http_client is only set for HTTP transport and needs cleanup.
"""
http_client: Optional[httpx.AsyncClient] = None
if self.transport_type == MCPTransport.stdio:
if not self.stdio_config:
raise ValueError("stdio_config is required for stdio transport")
server_params = StdioServerParameters(
command=self.stdio_config.get("command", ""),
args=self.stdio_config.get("args", []),
env=self.stdio_config.get("env", {}),
env=self._get_safe_stdio_env(self.stdio_config.get("env")),
)
return stdio_client(server_params), None
if self.transport_type == MCPTransport.sse:
headers = self._get_auth_headers()
httpx_client_factory = self._create_httpx_client_factory()
@ -260,14 +276,12 @@ class MCPClient:
),
None,
)
# HTTP transport (default)
if streamable_http_client is None:
raise ImportError(
"streamable_http_client is not available. "
"Please install mcp with HTTP support."
)
headers = self._get_auth_headers()
httpx_client_factory = self._create_httpx_client_factory()
verbose_logger.debug("litellm headers for streamable_http_client: %s", headers)
@ -281,6 +295,54 @@ class MCPClient:
)
return transport_ctx, http_client
def _get_safe_stdio_env(
self, provided_env: Optional[Dict[str, str]]
) -> Optional[Dict[str, str]]:
"""
Return a safe environment for the stdio subprocess.
If provided_env is set, we use it as-is.
If provided_env is None, we return a minimal allowlist from the parent environment
to avoid leaking sensitive LiteLLM keys (OPENAI_API_KEY, etc.) to sub-processes.
"""
if provided_env is not None:
return provided_env
# Minimal allowlist of safe/standard environment variables
safe_keys = {
"PATH",
"HOME",
"USER",
"LOGNAME",
"TMPDIR",
"TMP",
"TEMP",
"SHELL",
"LANG",
"LC_ALL",
# Node/Package manager caches
"NPM_CONFIG_CACHE",
"PNPM_HOME",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
# System info
"SYSTEMROOT",
"COMSPEC",
"PATHEXT",
"WINDIR",
}
safe_env = {}
for key in safe_keys:
if key in os.environ:
safe_env[key] = os.environ[key]
if "NPM_CONFIG_CACHE" not in safe_env:
safe_env["NPM_CONFIG_CACHE"] = MCP_NPM_CACHE_DIR
return safe_env
async def _execute_session_operation(
self,
transport_ctx: Any,
@ -288,13 +350,24 @@ class MCPClient:
) -> TSessionResult:
"""
Execute an operation within a transport and session context.
Handles entering/exiting contexts and running the operation.
Passes sampling/elicitation/logging callbacks to the ClientSession
so that upstream MCP servers can request LLM inference (sampling),
user input (elicitation), or send log messages.
"""
transport = await transport_ctx.__aenter__()
in_flight_error: Optional[BaseException] = None
try:
read_stream, write_stream = transport[0], transport[1]
session_ctx = ClientSession(read_stream, write_stream)
# Build session kwargs with optional callbacks
session_kwargs: Dict[str, Any] = {}
if self._sampling_callback is not None:
session_kwargs["sampling_callback"] = self._sampling_callback
if self._elicitation_callback is not None:
session_kwargs["elicitation_callback"] = self._elicitation_callback
if self._logging_callback is not None:
session_kwargs["logging_callback"] = self._logging_callback
session_ctx = ClientSession(read_stream, write_stream, **session_kwargs)
session = await session_ctx.__aenter__()
try:
init_result = await session.initialize()
@ -309,11 +382,21 @@ class MCPClient:
await session_ctx.__aexit__(None, None, None)
except BaseException as e:
verbose_logger.debug(f"Error during session context exit: {e}")
except BaseException as e:
in_flight_error = e
raise
finally:
try:
await transport_ctx.__aexit__(None, None, None)
except BaseException as e:
verbose_logger.debug(f"Error during transport context exit: {e}")
except BaseException as exit_error:
verbose_logger.debug(
f"Error during transport context exit: {exit_error}"
)
root_cause = _first_non_cancelled_cause(exit_error)
if root_cause is not None and isinstance(
in_flight_error, asyncio.CancelledError
):
raise root_cause from in_flight_error
async def run_with_session(
self, operation: Callable[[ClientSession], Awaitable[TSessionResult]]
@ -351,7 +434,6 @@ class MCPClient:
def _get_auth_headers(self) -> dict:
"""Generate authentication headers based on auth type."""
headers = {}
if self._mcp_auth_value:
if isinstance(self._mcp_auth_value, str):
if self.auth_type == MCPAuth.bearer_token:
@ -373,17 +455,14 @@ class MCPClient:
# Note: aws_sigv4 auth is not handled here — SigV4 requires per-request
# signing (including the body hash), so it uses httpx.Auth flow instead
# of static headers. See MCPSigV4Auth and _create_httpx_client_factory().
# update the headers with the extra headers
if self.extra_headers:
headers.update(self.extra_headers)
return headers
return _strip_header_whitespace(headers)
def _create_httpx_client_factory(self) -> Callable[..., httpx.AsyncClient]:
"""
Create a custom httpx client factory that uses LiteLLM's SSL configuration.
This factory follows the same CA bundle path logic as http_handler.py:
1. Check ssl_verify parameter (can be SSLContext, bool, or path to CA bundle)
2. Check SSL_VERIFY environment variable
@ -400,17 +479,14 @@ class MCPClient:
"""Create an httpx.AsyncClient with LiteLLM's SSL configuration."""
# Get unified SSL configuration using the same logic as http_handler.py
ssl_config = get_ssl_configuration(self.ssl_verify)
verbose_logger.debug(
f"MCP client using SSL configuration: {type(ssl_config).__name__}"
)
# Use SigV4 auth if configured and no explicit auth provided.
# The MCP SDK's sse_client and streamable_http_client call this
# factory without passing auth=, so self._aws_auth is used.
# For non-SigV4 clients, self._aws_auth is None — no behavior change.
effective_auth = auth if auth is not None else self._aws_auth
return httpx.AsyncClient(
headers=headers,
timeout=timeout,
@ -458,7 +534,6 @@ class MCPClient:
f"Server: {self.server_url or 'stdio'}, "
f"Transport: {self.transport_type}"
)
# Check if it's a stream/connection error
if "BrokenResourceError" in error_type or "Broken" in error_type:
verbose_logger.error(
@ -491,7 +566,6 @@ class MCPClient:
f"MCP Tool '{call_tool_request_params.name}' progress: "
f"{progress}/{total} ({percentage:.0f}%) - {message or ''}"
)
# Forward to Host if callback provided
if host_progress_callback:
try:
@ -514,14 +588,15 @@ class MCPClient:
)
return tool_result
except asyncio.CancelledError:
verbose_logger.warning("MCP client tool call was cancelled")
verbose_logger.warning(
f"MCP client tool call timed out after {self.timeout}s for {self.server_url}"
)
raise
except Exception as e:
import traceback
error_trace = traceback.format_exc()
verbose_logger.debug(f"MCP client tool call traceback:\n{error_trace}")
# Log detailed error information
error_type = type(e).__name__
verbose_logger.error(
@ -532,14 +607,12 @@ class MCPClient:
f"Server: {self.server_url or 'stdio'}, "
f"Transport: {self.transport_type}"
)
# Check if it's a stream/connection error
if "BrokenResourceError" in error_type or "Broken" in error_type:
verbose_logger.error(
"MCP client detected broken connection/stream - "
"the MCP server may have crashed, disconnected, or timed out."
)
# Return a default error result instead of raising
return MCPCallToolResult(
content=[
@ -577,14 +650,12 @@ class MCPClient:
f"Server: {self.server_url or 'stdio'}, "
f"Transport: {self.transport_type}"
)
# Check if it's a stream/connection error
if "BrokenResourceError" in error_type or "Broken" in error_type:
verbose_logger.error(
"MCP client detected broken connection/stream during list_tools - "
"the MCP server may have crashed, disconnected, or timed out"
)
# Return empty list instead of raising to allow graceful degradation
return []
@ -617,7 +688,6 @@ class MCPClient:
error_trace = traceback.format_exc()
verbose_logger.debug(f"MCP client get_prompt traceback:\n{error_trace}")
# Log detailed error information
error_type = type(e).__name__
verbose_logger.error(
@ -628,14 +698,12 @@ class MCPClient:
f"Server: {self.server_url or 'stdio'}, "
f"Transport: {self.transport_type}"
)
# Check if it's a stream/connection error
if "BrokenResourceError" in error_type or "Broken" in error_type:
verbose_logger.error(
"MCP client detected broken connection/stream during get_prompt - "
"the MCP server may have crashed, disconnected, or timed out."
)
raise
async def list_resources(self) -> list[Resource]:
@ -667,14 +735,12 @@ class MCPClient:
f"Server: {self.server_url or 'stdio'}, "
f"Transport: {self.transport_type}"
)
# Check if it's a stream/connection error
if "BrokenResourceError" in error_type or "Broken" in error_type:
verbose_logger.error(
"MCP client detected broken connection/stream during list_resources - "
"the MCP server may have crashed, disconnected, or timed out"
)
# Return empty list instead of raising to allow graceful degradation
return []
@ -709,14 +775,12 @@ class MCPClient:
f"Server: {self.server_url or 'stdio'}, "
f"Transport: {self.transport_type}"
)
# Check if it's a stream/connection error
if "BrokenResourceError" in error_type or "Broken" in error_type:
verbose_logger.error(
"MCP client detected broken connection/stream during list_resource_templates - "
"the MCP server may have crashed, disconnected, or timed out"
)
# Return empty list instead of raising to allow graceful degradation
return []
@ -742,7 +806,6 @@ class MCPClient:
error_trace = traceback.format_exc()
verbose_logger.debug(f"MCP client read_resource traceback:\n{error_trace}")
# Log detailed error information
error_type = type(e).__name__
verbose_logger.error(
@ -753,12 +816,10 @@ class MCPClient:
f"Server: {self.server_url or 'stdio'}, "
f"Transport: {self.transport_type}"
)
# Check if it's a stream/connection error
if "BrokenResourceError" in error_type or "Broken" in error_type:
verbose_logger.error(
"MCP client detected broken connection/stream during read_resource - "
"the MCP server may have crashed, disconnected, or timed out."
)
raise

View file

@ -37,6 +37,8 @@ from litellm.proxy._types import (
VirtualKeyEvent,
WebhookEvent,
)
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.types.integrations.slack_alerting import *
from ..email_templates.templates import *
@ -1231,7 +1233,7 @@ Model Info:
and recipient_user_id is not None
and prisma_client is not None
):
user_row = await prisma_client.db.litellm_usertable.find_unique(
user_row = await UserRepository(prisma_client).table.find_unique(
where={"user_id": recipient_user_id}
)
@ -1263,7 +1265,7 @@ Model Info:
team_id = webhook_event.team_id
team_name = "Default Team"
if team_id is not None and prisma_client is not None:
team_row = await prisma_client.db.litellm_teamtable.find_unique(
team_row = await TeamRepository(prisma_client).table.find_unique(
where={"team_id": team_id}
)
if team_row is not None:

View file

@ -8,18 +8,23 @@ from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes impor
BaseLLMObsOTELAttributes,
safe_set_attribute,
)
from litellm.litellm_core_utils.redact_messages import (
should_redact_message_logging,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.types.utils import StandardLoggingPayload
if TYPE_CHECKING:
from opentelemetry.trace import Span
from litellm.integrations._types.open_inference import (
MessageAttributes,
ImageAttributes,
SpanAttributes,
AudioAttributes,
EmbeddingAttributes,
ImageAttributes,
MessageAttributes,
MessageContentAttributes,
OpenInferenceSpanKindValues,
SpanAttributes,
ToolCallAttributes,
)
@ -53,40 +58,24 @@ class ArizeOTELAttributes(BaseLLMObsOTELAttributes):
msg.get("content", ""),
)
@staticmethod
@override
def set_response_output_messages(span: "Span", response_obj):
"""
Sets output message attributes on the span from the LLM response.
Args:
span: The OpenTelemetry span to set attributes on
response_obj: The response object containing choices with messages
"""
from litellm.integrations._types.open_inference import (
MessageAttributes,
SpanAttributes,
)
# Additive: emit structured tool_calls / multimodal content
# so Arize/Phoenix can render tool-using and image-bearing
# turns. These set NEW attribute keys (MESSAGE_TOOL_CALLS /
# MESSAGE_NAME / MESSAGE_TOOL_CALL_ID / MESSAGE_CONTENTS.*) —
# never replace the MESSAGE_CONTENT write above.
_safe_emit(
f"input message extras (idx={idx})",
_emit_input_message_extras,
span,
prefix,
msg,
)
for idx, choice in enumerate(response_obj.get("choices", [])):
response_message = choice.get("message", {})
safe_set_attribute(
span,
SpanAttributes.OUTPUT_VALUE,
response_message.get("content", ""),
)
# This shows up under `output_messages` tab on the span page.
prefix = f"{SpanAttributes.LLM_OUTPUT_MESSAGES}.{idx}"
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_ROLE}",
response_message.get("role"),
)
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_CONTENT}",
response_message.get("content", ""),
)
# Note: `BaseLLMObsOTELAttributes.set_response_output_messages` is not
# overridden here. The live code path uses `_set_choice_outputs` (called
# via `_set_response_attributes` from `set_attributes`) which handles
# tool_calls, multimodal output, embeddings, audio, images, and structured
# outputs in a single place.
def _set_response_attributes(span: "Span", response_obj):
@ -106,11 +95,17 @@ def _set_response_attributes(span: "Span", response_obj):
def _set_choice_outputs(span: "Span", response_obj, msg_attrs, span_attrs):
for idx, choice in enumerate(response_obj.get("choices", [])):
response_message = choice.get("message", {})
safe_set_attribute(
span,
span_attrs.OUTPUT_VALUE,
response_message.get("content", ""),
)
content = response_message.get("content", "")
# Tool-only assistant responses have empty content; serialize the
# tool_calls into OUTPUT_VALUE so Arize's "Output" pane isn't blank.
output_value = content
if not output_value:
tool_calls = _get_tool_calls(response_message)
if tool_calls:
output_value = _summarize_tool_calls_for_output(tool_calls)
safe_set_attribute(span, span_attrs.OUTPUT_VALUE, output_value)
prefix = f"{span_attrs.LLM_OUTPUT_MESSAGES}.{idx}"
safe_set_attribute(
span,
@ -120,7 +115,18 @@ def _set_choice_outputs(span: "Span", response_obj, msg_attrs, span_attrs):
safe_set_attribute(
span,
f"{prefix}.{msg_attrs.MESSAGE_CONTENT}",
response_message.get("content", ""),
content,
)
# Additive: emit assistant tool_calls so tool-using turns render in
# Arize/Phoenix. Sets new MESSAGE_TOOL_CALLS keys only — does not
# change MESSAGE_CONTENT/MESSAGE_ROLE writes above.
_safe_emit(
f"output tool_calls (idx={idx})",
_emit_message_tool_calls,
span,
prefix,
response_message,
)
@ -278,6 +284,43 @@ def _set_usage_outputs(span: "Span", response_obj, span_attrs):
reasoning_tokens,
)
# Additive: cache token breakdown so prompt-caching savings render in
# Arize. Sources covered:
# - OpenAI Chat Completions: `prompt_tokens_details.cached_tokens`
# - Anthropic / Bedrock-Anthropic: `cache_read_input_tokens`,
# `cache_creation_input_tokens`
# All emits are conditional, so when none of these fields exist (the
# situation in the existing test fixtures) no extra attributes are set.
prompt_token_details = _safe_get(usage, "prompt_tokens_details") or _safe_get(
usage, "input_tokens_details"
)
cache_read = _safe_get(prompt_token_details, "cached_tokens") or _safe_get(
usage, "cache_read_input_tokens"
)
if cache_read:
safe_set_attribute(
span,
span_attrs.LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ,
cache_read,
)
# Anthropic / Bedrock-Anthropic only — OpenAI's `prompt_tokens_details`
# does not expose a cache-write count, so we read straight off `usage`.
cache_write = _safe_get(usage, "cache_creation_input_tokens")
if cache_write:
safe_set_attribute(
span,
span_attrs.LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE,
cache_write,
)
audio_prompt_tokens = _safe_get(prompt_token_details, "audio_tokens")
if audio_prompt_tokens:
safe_set_attribute(
span,
span_attrs.LLM_TOKEN_COUNT_PROMPT_DETAILS_AUDIO,
audio_prompt_tokens,
)
def _infer_open_inference_span_kind(call_type: Optional[str]) -> str:
"""
@ -321,6 +364,10 @@ def _infer_open_inference_span_kind(call_type: Optional[str]) -> str:
"videos",
"realtime",
"pass_through",
# `passthrough` (no underscore) is what real call_types use:
# `allm_passthrough_route`, `llm_passthrough_route`. Without
# this they fell through to UNKNOWN, blanking span.kind.
"passthrough",
"anthropic_messages",
"ocr",
)
@ -396,6 +443,18 @@ def set_attributes(
"""
Populates span with OpenInference-compliant LLM attributes for Arize and Phoenix tracing.
"""
# Coerce non-dict response objects (e.g. httpx.Response from passthrough
# routes) into a dict so downstream `.get()` calls don't crash. Existing
# dict / `.get()`-bearing objects (incl. Pydantic OpenAI Responses API
# models) are returned unchanged, preserving the existing test behavior.
response_obj_for_attrs = _coerce_response_obj_for_attrs(response_obj)
# Set span.kind defensively before anything else. If a downstream step
# throws, the span still has a kind so Arize can render it correctly
# (an LLM call instead of UNKNOWN). This is the single source of truth
# for span.kind — no late re-write happens below.
_safe_emit("early span kind", _set_early_span_kind, span, kwargs)
try:
optional_params = _sanitize_optional_params(kwargs.get("optional_params"))
litellm_params = kwargs.get("litellm_params", {}) or {}
@ -415,25 +474,22 @@ def set_attributes(
metadata_tools = _extract_metadata_tools(metadata)
optional_tools = _extract_optional_tools(optional_params)
call_type = standard_logging_payload.get("call_type")
_set_request_attributes(
span=span,
kwargs=kwargs,
standard_logging_payload=standard_logging_payload,
optional_params=optional_params,
litellm_params=litellm_params,
response_obj=response_obj,
response_obj=response_obj_for_attrs,
span_attrs=SpanAttributes,
)
span_kind = _infer_open_inference_span_kind(call_type=call_type)
# span.kind was already set above by `_set_early_span_kind`. We do
# NOT re-write it here based on tool presence: a chat completion
# that passes `tools=[...]` (or returns `tool_calls`) is still an
# LLM call per the OpenInference spec — TOOL is reserved for actual
# tool execution spans, not LLM calls that request tools.
_set_tool_attributes(span, optional_tools, metadata_tools)
if (
optional_tools or metadata_tools
) and span_kind != OpenInferenceSpanKindValues.TOOL.value:
span_kind = OpenInferenceSpanKindValues.TOOL.value
safe_set_attribute(span, SpanAttributes.OPENINFERENCE_SPAN_KIND, span_kind)
attributes.set_messages(span, kwargs)
model_params = (
@ -443,7 +499,7 @@ def set_attributes(
)
_set_model_params(span, model_params, SpanAttributes)
_set_response_attributes(span=span, response_obj=response_obj)
_set_response_attributes(span=span, response_obj=response_obj_for_attrs)
except Exception as e:
verbose_logger.error(
@ -452,6 +508,22 @@ def set_attributes(
if hasattr(span, "record_exception"):
span.record_exception(e)
# Additive emitters. Each is independently guarded so a failure can never
# blank the attributes set by the main try-block above. New attributes are
# written under new keys; existing attributes are not overwritten.
slp = kwargs.get("standard_logging_object")
_safe_emit("session/user attrs", _set_session_and_user_attrs, span, kwargs, slp)
_safe_emit("response cost", _set_response_cost_attr, span, slp)
_safe_emit(
"passthrough normalization",
_maybe_normalize_passthrough,
span,
kwargs,
response_obj,
response_obj_for_attrs,
slp,
)
def _sanitize_optional_params(optional_params: Optional[dict]) -> dict:
if not isinstance(optional_params, dict):
@ -534,3 +606,529 @@ def _set_model_params(span: "Span", model_params: Optional[dict], span_attrs) ->
user_id = model_params.get("user")
if user_id is not None:
safe_set_attribute(span, span_attrs.USER_ID, user_id)
# ---------------------------------------------------------------------------
# Additive rendering helpers (introduced to enhance Arize/Phoenix rendering
# without changing any previously-emitted attribute keys or values).
# ---------------------------------------------------------------------------
def _safe_emit(label: str, fn, *args, **kwargs) -> None:
"""Run an additive attribute emitter, swallowing any error so it cannot
blank attributes set elsewhere on the span. Failures are logged at debug.
"""
try:
fn(*args, **kwargs)
except Exception as e:
verbose_logger.debug("[Arize] %s skipped: %s", label, e)
def _set_early_span_kind(span: "Span", kwargs: dict) -> None:
"""Defensively set OPENINFERENCE_SPAN_KIND before any other logic runs."""
slp = kwargs.get("standard_logging_object")
call_type = slp.get("call_type") if isinstance(slp, dict) else None
safe_set_attribute(
span,
SpanAttributes.OPENINFERENCE_SPAN_KIND,
_infer_open_inference_span_kind(call_type=call_type),
)
def _coerce_response_obj_for_attrs(response_obj):
"""Return a `.get`-compatible view of `response_obj` when possible.
- dicts and Pydantic models that already expose `.get` are returned
unchanged (preserves all current behavior, including the Responses API
flow which relies on Pydantic attribute access).
- `httpx.Response` and other text-only responses (passthrough routes)
are JSON-decoded so the standard extraction paths can read fields like
`id`, `model`, and `usage`. On failure the original object is returned
so behavior is no worse than today.
"""
if response_obj is None or hasattr(response_obj, "get"):
return response_obj
text = getattr(response_obj, "text", None)
if isinstance(text, str) and text:
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return response_obj
def _coerce_text(value) -> Optional[str]:
"""Best-effort text extraction from a message-content value.
Returns None when no textual portion can be derived. Handles:
- plain strings
- lists of OpenAI-style content parts (`{"type": "text", "text": ...}`)
- lists of Anthropic-style content parts (`{"type": "text", "text": ...}`
or `{"type": "input_text", "text": ...}`)
"""
if value is None:
return None
if isinstance(value, str):
return value
if isinstance(value, list):
parts = []
for part in value:
if isinstance(part, str):
parts.append(part)
elif isinstance(part, dict):
text = part.get("text") or part.get("input_text")
if isinstance(text, str):
parts.append(text)
if parts:
return "\n".join(parts)
return None
def _to_plain_dict(value):
"""Best-effort: coerce a value (Pydantic model / dict / None) to a dict.
Returns the original value when no safe conversion exists. Used to bridge
OpenAI Pydantic message/tool_call objects into the dict-based helpers.
"""
if value is None or isinstance(value, dict):
return value
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
try:
return model_dump()
except Exception:
pass
return value
def _get_tool_calls(message) -> Optional[list]:
"""Return ``message.tool_calls`` only when it's a non-empty list.
Works for dicts and Pydantic message objects via ``_safe_get``.
"""
tool_calls = _safe_get(message, "tool_calls")
return tool_calls if isinstance(tool_calls, list) and tool_calls else None
def _normalize_tool_call(raw_tc) -> Optional[Dict[str, Any]]:
"""Normalize a single tool_call (dict or Pydantic) into a stable shape:
{"id": str|None, "type": str, "function": {"name": str|None, "arguments": str|None}}
Arguments are coerced to a JSON string per OpenInference convention.
Returns ``None`` when ``raw_tc`` cannot be coerced to a dict.
"""
tc = _to_plain_dict(raw_tc)
if not isinstance(tc, dict):
return None
function = _to_plain_dict(tc.get("function"))
name = function.get("name") if isinstance(function, dict) else None
args = function.get("arguments") if isinstance(function, dict) else None
if args is not None and not isinstance(args, str):
try:
args = json.dumps(args)
except Exception:
args = str(args)
return {
"id": tc.get("id"),
"type": tc.get("type", "function"),
"function": {"name": name, "arguments": args},
}
def _summarize_tool_calls_for_output(tool_calls) -> str:
"""Render a tool_calls list as a compact JSON string for OUTPUT_VALUE.
Best-effort: returns ``str(tool_calls)`` if anything unexpected happens
so OUTPUT_VALUE is never blanked on a malformed payload.
"""
try:
normalized = [n for n in (_normalize_tool_call(tc) for tc in tool_calls) if n]
return json.dumps({"tool_calls": normalized})
except Exception:
return str(tool_calls)
def _emit_message_tool_calls(span: "Span", prefix: str, message) -> None:
"""Emit ``MESSAGE_TOOL_CALLS.*`` for an assistant message that requested
tool calls. Pure addition: only writes when ``tool_calls`` is non-empty.
Accepts dicts or Pydantic message objects (e.g. ``litellm.Message``); the
same applies to each tool_call entry.
"""
tool_calls = _get_tool_calls(message)
if not tool_calls:
return
for tc_idx, raw_tc in enumerate(tool_calls):
tc = _normalize_tool_call(raw_tc)
if tc is None:
continue
tc_prefix = f"{prefix}.{MessageAttributes.MESSAGE_TOOL_CALLS}.{tc_idx}"
if tc["id"]:
safe_set_attribute(
span, f"{tc_prefix}.{ToolCallAttributes.TOOL_CALL_ID}", tc["id"]
)
fn = tc["function"]
if fn["name"]:
safe_set_attribute(
span,
f"{tc_prefix}.{ToolCallAttributes.TOOL_CALL_FUNCTION_NAME}",
fn["name"],
)
if fn["arguments"] is not None:
safe_set_attribute(
span,
f"{tc_prefix}.{ToolCallAttributes.TOOL_CALL_FUNCTION_ARGUMENTS_JSON}",
fn["arguments"],
)
def _emit_input_message_extras(span: "Span", prefix: str, message: dict) -> None:
"""Emit additive attributes for an input message:
- `MESSAGE_NAME` and `MESSAGE_TOOL_CALL_ID` (commonly set on tool-result
messages so traces show which tool produced which result).
- `MESSAGE_TOOL_CALLS.*` when an assistant message requested tools.
- `MESSAGE_CONTENTS.*` structured content for list-shaped content
(multimodal text + image parts). The plain `MESSAGE_CONTENT` write is
still performed by the caller, so renderers that only read the legacy
key continue to work.
"""
if not isinstance(message, dict):
return
name = message.get("name")
if name:
safe_set_attribute(span, f"{prefix}.{MessageAttributes.MESSAGE_NAME}", name)
tool_call_id = message.get("tool_call_id")
if tool_call_id:
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_TOOL_CALL_ID}",
tool_call_id,
)
_emit_message_tool_calls(span, prefix, message)
content = message.get("content")
if isinstance(content, list):
contents_prefix = f"{prefix}.{MessageAttributes.MESSAGE_CONTENTS}"
for part_idx, part in enumerate(content):
if not isinstance(part, dict):
continue
part_prefix = f"{contents_prefix}.{part_idx}"
part_type = part.get("type")
if part_type in ("text", "input_text"):
text = part.get("text")
if isinstance(text, str):
safe_set_attribute(
span,
f"{part_prefix}.{MessageContentAttributes.MESSAGE_CONTENT_TYPE}",
"text",
)
safe_set_attribute(
span,
f"{part_prefix}.{MessageContentAttributes.MESSAGE_CONTENT_TEXT}",
text,
)
elif part_type in ("image_url", "image", "input_image"):
url = None
image = part.get("image_url")
if isinstance(image, dict):
url = image.get("url")
elif isinstance(image, str):
url = image
if not url:
# Anthropic-style source.{type=base64,media_type,data}
source = part.get("source")
if isinstance(source, dict) and source.get("data"):
media_type = source.get("media_type", "image/jpeg")
url = f"data:{media_type};base64,{source['data']}"
elif isinstance(part.get("url"), str):
url = part["url"]
if url:
safe_set_attribute(
span,
f"{part_prefix}.{MessageContentAttributes.MESSAGE_CONTENT_TYPE}",
"image",
)
safe_set_attribute(
span,
f"{part_prefix}.message_content.image.image.url",
url,
)
def _set_session_and_user_attrs(
span: "Span", kwargs: dict, standard_logging_payload
) -> None:
"""Emit `SESSION_ID` / `USER_ID` / team metadata when source data exists.
`SESSION_ID` is emitted only when an explicit end-user identifier exists
(`metadata.user_api_key_end_user_id`). We deliberately do NOT fall back
to `trace_id`, because that would create a distinct "session" for every
single request and distort Arize's Session-grouping analytics. The
`trace_id` is still emitted under its own `litellm.trace_id` key so
spans remain filterable by trace.
USER_ID is *only* emitted when no upstream path (model_params.user or
optional_params.user) has already set it, to avoid overwriting an
existing value with a possibly-different one from API-key metadata.
"""
if not isinstance(standard_logging_payload, dict):
return
metadata = standard_logging_payload.get("metadata") or {}
if not isinstance(metadata, dict):
return
session_id = metadata.get("user_api_key_end_user_id")
if session_id:
safe_set_attribute(span, SpanAttributes.SESSION_ID, str(session_id))
trace_id = standard_logging_payload.get("trace_id")
if trace_id:
safe_set_attribute(span, "litellm.trace_id", str(trace_id))
optional_params = kwargs.get("optional_params") or {}
model_params = standard_logging_payload.get("model_parameters") or {}
has_user_already = bool(
(isinstance(optional_params, dict) and optional_params.get("user"))
or (isinstance(model_params, dict) and model_params.get("user"))
)
if not has_user_already:
user_id = metadata.get("user_api_key_user_id")
if user_id:
safe_set_attribute(span, SpanAttributes.USER_ID, str(user_id))
team_id = metadata.get("user_api_key_team_id")
if team_id:
safe_set_attribute(span, "litellm.team_id", str(team_id))
team_alias = metadata.get("user_api_key_team_alias")
if team_alias:
safe_set_attribute(span, "litellm.team_alias", str(team_alias))
key_alias = metadata.get("user_api_key_alias")
if key_alias:
safe_set_attribute(span, "litellm.key_alias", str(key_alias))
def _set_response_cost_attr(span: "Span", standard_logging_payload) -> None:
"""Emit cost attributes from the StandardLoggingPayload when present.
Uses the OpenInference `llm.cost.total` key so Arize / Phoenix can
surface the cost in their "Total Cost" column. LiteLLM only tracks a
single total in `StandardLoggingPayload.response_cost`, so we cannot
split it into prompt/completion. We also keep the legacy
`llm.response.cost` key for back-compat with any consumer querying it.
"""
if not isinstance(standard_logging_payload, dict):
return
cost = standard_logging_payload.get("response_cost")
if cost is None:
return
try:
cost_value = float(cost)
except (TypeError, ValueError):
return
safe_set_attribute(span, "llm.cost.total", cost_value)
safe_set_attribute(span, "llm.response.cost", cost_value)
def _is_passthrough_call_type(call_type: Optional[str]) -> bool:
if not call_type:
return False
lowered = str(call_type).lower()
return "passthrough" in lowered or "pass_through" in lowered
def _maybe_normalize_passthrough(
span: "Span",
kwargs: dict,
raw_response_obj,
coerced_response_obj,
standard_logging_payload,
) -> None:
"""Surface input/output text for passthrough routes (e.g. Bedrock
InvokeModel) so the parent span renders as more than `usage` numbers.
Only runs when `call_type` is a passthrough variant. Reads from:
- `kwargs["additional_args"]["complete_input_dict"]` for input
- the coerced response (or `kwargs["original_response"]`) for output
All emits are best-effort: if the provider shape isn't recognized the
helper exits silently. Existing chat/completion paths never enter this
helper because their call_type doesn't contain "passthrough".
TEMPORARY BRIDGE: passthrough handlers don't populate the
StandardLoggingPayload `messages` field today (they call
`transform_response(messages=[])`), so the input is only available via
`additional_args.complete_input_dict`. The proper fix is upstream in
`base_passthrough_logging_handler._create_response_logging_payload()`:
once that populates SLP `messages`/`response`, every callback gets
passthrough I/O (with central redaction) for free and this helper's
`complete_input_dict` fallback can be deleted. See follow-up issue.
"""
call_type = (
standard_logging_payload.get("call_type")
if isinstance(standard_logging_payload, dict)
else None
)
if not _is_passthrough_call_type(call_type):
return
# Respect LiteLLM's central message-redaction contract. The normal
# chat/completion path is redacted by `perform_redaction` before
# callbacks run, but `complete_input_dict` (read below) is NOT covered by
# that layer — so without this gate, an operator who enabled redaction
# would still see raw passthrough prompts in Arize. Skip entirely when
# redaction is on so neither input nor output leaks through this bridge.
if should_redact_message_logging(kwargs):
return
# --- INPUT --------------------------------------------------------------
additional_args = kwargs.get("additional_args") or {}
complete_input_dict = (
additional_args.get("complete_input_dict")
if isinstance(additional_args, dict)
else None
)
if isinstance(complete_input_dict, dict):
_set_passthrough_input_attributes(span, complete_input_dict.get("messages"))
# --- OUTPUT -------------------------------------------------------------
parsed_response = _parse_passthrough_response(
raw_response_obj, coerced_response_obj, kwargs
)
if not isinstance(parsed_response, dict):
return
_set_passthrough_output_attributes(span, parsed_response)
def _set_passthrough_input_attributes(span: "Span", messages) -> None:
"""Render passthrough request messages into INPUT_VALUE + LLM_INPUT_MESSAGES."""
if not (isinstance(messages, list) and messages):
return
# Set INPUT_VALUE from the last user message text if discoverable.
last_text = None
for msg in reversed(messages):
if isinstance(msg, dict):
last_text = _coerce_text(msg.get("content"))
if last_text:
break
if last_text:
safe_set_attribute(span, SpanAttributes.INPUT_VALUE, last_text)
# Mirror messages into LLM_INPUT_MESSAGES so the input pane renders.
for idx, msg in enumerate(messages):
if not isinstance(msg, dict):
continue
prefix = f"{SpanAttributes.LLM_INPUT_MESSAGES}.{idx}"
role = msg.get("role")
if role:
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_ROLE}",
role,
)
text = _coerce_text(msg.get("content"))
if text is not None:
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_CONTENT}",
text,
)
def _set_passthrough_output_attributes(span: "Span", parsed_response: dict) -> None:
"""Render passthrough response into OUTPUT_VALUE + LLM_OUTPUT_MESSAGES."""
# Anthropic / Bedrock-Anthropic: `content` is a list of typed parts.
content_list = parsed_response.get("content")
if isinstance(content_list, list) and content_list:
texts = []
for part in content_list:
if isinstance(part, dict) and isinstance(part.get("text"), str):
texts.append(part["text"])
joined = "\n\n".join(t for t in texts if t)
if joined:
safe_set_attribute(span, SpanAttributes.OUTPUT_VALUE, joined)
prefix = f"{SpanAttributes.LLM_OUTPUT_MESSAGES}.0"
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_ROLE}",
parsed_response.get("role", "assistant"),
)
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_CONTENT}",
joined,
)
# OpenAI-style passthrough: `choices[0].message.content`
choices = parsed_response.get("choices")
if isinstance(choices, list) and choices:
first = choices[0]
if isinstance(first, dict):
msg = first.get("message")
if isinstance(msg, dict):
text = _coerce_text(msg.get("content"))
if text:
safe_set_attribute(span, SpanAttributes.OUTPUT_VALUE, text)
prefix = f"{SpanAttributes.LLM_OUTPUT_MESSAGES}.0"
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_ROLE}",
msg.get("role", "assistant"),
)
safe_set_attribute(
span,
f"{prefix}.{MessageAttributes.MESSAGE_CONTENT}",
text,
)
def _parse_passthrough_response(raw_response_obj, coerced_response_obj, kwargs):
"""Return a dict view of the provider response for passthrough routes."""
# Prefer the coerced view (already JSON-parsed for httpx.Response).
candidates = []
if isinstance(coerced_response_obj, dict):
candidates.append(coerced_response_obj)
if (
isinstance(raw_response_obj, dict)
and raw_response_obj is not coerced_response_obj
):
candidates.append(raw_response_obj)
for candidate in candidates:
# StandardPassThroughResponseObject wrapper: {"response": "..."}.
if (
"response" in candidate
and "content" not in candidate
and "choices" not in candidate
):
inner = candidate.get("response")
if isinstance(inner, str):
try:
parsed = json.loads(inner)
if isinstance(parsed, dict):
return parsed
except Exception:
continue
if isinstance(inner, dict):
return inner
else:
return candidate
# Fallback: kwargs["original_response"] from the OTel base path.
original = kwargs.get("original_response") if isinstance(kwargs, dict) else None
if isinstance(original, dict):
return original
if isinstance(original, str):
try:
parsed = json.loads(original)
if isinstance(parsed, dict):
return parsed
except Exception:
return None
return None

View file

@ -104,6 +104,51 @@
},
"description": "Datadog Custom Metrics Integration"
},
{
"id": "galileo",
"displayName": "Galileo",
"logo": "galileo.ico",
"supports_key_team_logging": false,
"dynamic_params": {
"GALILEO_API_KEY": {
"type": "password",
"ui_name": "API Key",
"description": "Galileo Cloud API key (app.galileo.ai). Omit for enterprise username/password auth.",
"required": false
},
"GALILEO_PROJECT_ID": {
"type": "text",
"ui_name": "Project ID",
"description": "Galileo project ID to log traces to",
"required": true
},
"GALILEO_LOG_STREAM_ID": {
"type": "text",
"ui_name": "Log Stream ID",
"description": "Galileo log stream ID for v2 spans logging (optional)",
"required": false
},
"GALILEO_BASE_URL": {
"type": "text",
"ui_name": "Base URL",
"description": "Galileo API base URL (e.g. https://api.galileo.ai for Cloud, or your enterprise API URL)",
"required": false
},
"GALILEO_USERNAME": {
"type": "text",
"ui_name": "Username",
"description": "Galileo enterprise username (legacy Observe auth; use instead of API key)",
"required": false
},
"GALILEO_PASSWORD": {
"type": "password",
"ui_name": "Password",
"description": "Galileo enterprise password (legacy Observe auth)",
"required": false
}
},
"description": "Galileo AI Observability Integration"
},
{
"id": "datadog_cost_management",
"displayName": "Datadog Cost Management",

View file

@ -47,9 +47,29 @@ from litellm.exceptions import (
BlockedPiiEntityError,
GuardrailRaisedException,
ModifyResponseException,
SensitiveDataRouteException,
)
def get_session_id_from_request_data(request_data: Dict[str, Any]) -> Optional[str]:
"""Extract session_id from request data (litellm_session_id or metadata)."""
session_id = request_data.get("litellm_session_id")
if session_id:
return str(session_id)
metadata = request_data.get("metadata") or {}
session_id = metadata.get("session_id")
if session_id:
return str(session_id)
litellm_metadata = request_data.get("litellm_metadata") or {}
session_id = litellm_metadata.get("session_id")
if session_id:
return str(session_id)
return None
class CustomGuardrail(CustomLogger):
# If True, during_call runs async_moderation_hook instead of the unified apply_guardrail path.
use_native_during_call_hook: ClassVar[bool] = False
@ -68,6 +88,9 @@ class CustomGuardrail(CustomLogger):
end_session_after_n_fails: Optional[int] = None,
on_violation: Optional[str] = None,
realtime_violation_message: Optional[str] = None,
on_sensitive_data: Optional[str] = None,
sensitive_data_route_to_model: Optional[str] = None,
sticky_session_routing: bool = True,
**kwargs,
):
"""
@ -83,6 +106,9 @@ class CustomGuardrail(CustomLogger):
end_session_after_n_fails: For /v1/realtime sessions, end the session after this many violations
on_violation: For /v1/realtime sessions, 'warn' or 'end_session'
realtime_violation_message: Message the bot speaks aloud when a /v1/realtime guardrail fires
on_sensitive_data: Action when sensitive data is detected. 'block' (default) or 'route'
sensitive_data_route_to_model: Model to route to when on_sensitive_data='route'
sticky_session_routing: When True, all subsequent requests in the session use the same model
"""
self.guardrail_name = guardrail_name
self.supported_event_hooks = supported_event_hooks
@ -96,6 +122,11 @@ class CustomGuardrail(CustomLogger):
self.end_session_after_n_fails: Optional[int] = end_session_after_n_fails
self.on_violation: Optional[str] = on_violation
self.realtime_violation_message: Optional[str] = realtime_violation_message
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
if supported_event_hooks:
## validate event_hook is in supported_event_hooks
@ -167,6 +198,108 @@ class CustomGuardrail(CustomLogger):
detection_info=detection_info,
)
def raise_sensitive_data_route_exception(
self,
route_to_model: str,
request_data: Dict[str, Any],
detection_info: Optional[Dict[str, Any]] = None,
) -> None:
"""
Raise an exception to reroute the request to a different model.
Use this when sensitive data is detected and the guardrail is configured
to route to an on-premise model instead of blocking.
The exception will reroute this request to the specified model. When
sticky_session_routing is enabled (the default), it also stores the
routing decision so subsequent requests in this session reuse the model.
Args:
route_to_model: The model to route this request (and session) to
request_data: The original request data dictionary
detection_info: Optional non-sensitive detection metadata (e.g. matched
entity types, rule ids, scores). This is surfaced in request metadata
and logs, so it must not contain the raw detected sensitive values.
Raises:
SensitiveDataRouteException: Always raises to trigger rerouting
"""
session_id = self._get_session_id_from_request_data(request_data)
if not session_id:
raise ValueError(
"Cannot route sensitive data without a session_id. "
"Ensure the request includes a session_id in metadata or headers."
)
raise SensitiveDataRouteException(
route_to_model=route_to_model,
session_id=session_id,
guardrail_name=self.guardrail_name,
detection_info=detection_info,
sticky_session_routing=self.sticky_session_routing,
)
def _get_session_id_from_request_data(
self, request_data: Dict[str, Any]
) -> Optional[str]:
"""Extract session_id from request data."""
return get_session_id_from_request_data(request_data)
def should_route_on_sensitive_data(self) -> bool:
"""
Returns True if this guardrail is configured to route requests
to a different model when sensitive data is detected.
"""
return (
self.on_sensitive_data == "route"
and self.sensitive_data_route_to_model is not None
)
def handle_sensitive_data_detection(
self,
request_data: Dict[str, Any],
detection_info: Optional[Dict[str, Any]] = None,
) -> None:
"""
Handle sensitive data detection based on guardrail configuration.
If on_sensitive_data='route', raises SensitiveDataRouteException to reroute.
Otherwise, raises GuardrailRaisedException to block. When routing is
configured but the request carries no session_id, routing is not possible
so the request falls back to a graceful block.
Args:
request_data: The request data dictionary
detection_info: Optional non-sensitive detection metadata. When routing,
this is surfaced in request metadata and logs, so it must not contain
the raw detected sensitive values.
Raises:
SensitiveDataRouteException: When configured to route and a session_id is present
GuardrailRaisedException: When configured to block, or when routing is
configured but no session_id is available
"""
if self.should_route_on_sensitive_data():
try:
self.raise_sensitive_data_route_exception(
route_to_model=self.sensitive_data_route_to_model, # type: ignore
request_data=request_data,
detection_info=detection_info,
)
except ValueError:
raise GuardrailRaisedException(
message=(
f"Sensitive data detected by {self.guardrail_name} "
"(routing skipped: request has no session_id)"
),
guardrail_name=self.guardrail_name,
)
else:
raise GuardrailRaisedException(
message=f"Sensitive data detected by {self.guardrail_name}",
guardrail_name=self.guardrail_name,
)
@staticmethod
def get_config_model() -> Optional[Type["GuardrailConfigModel"]]:
"""
@ -662,6 +795,16 @@ class CustomGuardrail(CustomLogger):
request_data["metadata"] = {}
_append_guardrail_info(request_data["metadata"])
# Emit the otel guardrail span here, where every guardrail execution lands,
# rather than relying on a post-call hook that does not fire on every path
# (e.g. a pass-through request that passes its guardrails).
try:
from litellm.integrations.otel.logger import emit_guardrail_span
emit_guardrail_span(slg)
except Exception:
pass
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
@ -743,12 +886,20 @@ class CustomGuardrail(CustomLogger):
Guardrails signal intentional blocks by raising:
- GuardrailRaisedException (generic guardrail API, tool permission)
- BlockedPiiEntityError (Presidio PII detection)
- SensitiveDataRouteException (sensitive-data reroute to on-premise model)
- HTTPException with status 400 (content policy violation)
- ModifyResponseException (passthrough mode violation)
"""
if isinstance(e, ModifyResponseException):
return True
if isinstance(e, (GuardrailRaisedException, BlockedPiiEntityError)):
if isinstance(
e,
(
GuardrailRaisedException,
BlockedPiiEntityError,
SensitiveDataRouteException,
),
):
return True
if (
HTTPException is not None

View file

@ -7,6 +7,7 @@ from typing import List, Optional
from litellm._logging import verbose_logger, verbose_proxy_logger
from litellm.proxy._types import WebhookEvent
from litellm.repositories.team_repository import TeamRepository
# we use this for the email header, please send a test email if you change this. verify it looks good on email
LITELLM_LOGO_URL = "https://litellm-listing.s3.amazonaws.com/litellm_logo.png"
@ -24,7 +25,7 @@ async def get_all_team_member_emails(team_id: Optional[str] = None) -> list:
if prisma_client is None:
raise Exception("Not connected to DB!")
team_row = await prisma_client.db.litellm_teamtable.find_unique(
team_row = await TeamRepository(prisma_client).table.find_unique(
where={
"team_id": team_id,
}

View file

@ -1,8 +1,13 @@
from __future__ import annotations
import json
import os
import re
from typing import Any, Dict, List, Optional, Tuple, cast
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union, cast
import httpx
from pydantic import BaseModel, Field
import litellm
@ -12,11 +17,15 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_content_list_to_str,
get_content_from_model_response,
)
from litellm.types.llms.openai import (
AllMessageValues,
HttpxBinaryResponseContent,
ResponsesAPIResponse,
)
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.types.llms.openai import AllMessageValues
GALILEO_CLOUD_API_BASE_URL = "https://api.galileo.ai"
# Cap the in-memory buffer so persistent flush failures (e.g. Galileo
@ -33,6 +42,11 @@ class LLMResponse(BaseModel):
model: str
num_input_tokens: int
num_output_tokens: int
num_total_tokens: int
cost: Optional[float] = Field(
default=None,
description="Total cost of the LLM call in USD as computed by LiteLLM.",
)
output_logprobs: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional. When available, logprobs are used to compute Uncertainty.",
@ -121,10 +135,14 @@ class GalileoObserve(CustomLogger):
@staticmethod
def _galileo_input_messages(
messages: Optional[List[Any]], input_text: str
messages: Optional[Any], input_text: str
) -> List[Dict[str, str]]:
if isinstance(messages, dict):
messages = messages.get("messages")
if not messages:
return [{"role": "user", "content": input_text}]
if not isinstance(messages, list):
return [{"role": "user", "content": input_text}]
galileo_messages: List[Dict[str, str]] = []
for message in messages:
@ -147,13 +165,59 @@ class GalileoObserve(CustomLogger):
return [{"role": "user", "content": input_text}]
@staticmethod
def _record_to_v2_span(record: Dict[str, Any]) -> Dict[str, Any]:
created_at = record.get("created_at", "")
def _local_timezone():
return datetime.now().astimezone().tzinfo or timezone.utc
@staticmethod
def _format_created_at(dt: Union[datetime, Any]) -> str:
"""Serialize timestamps as UTC ISO-8601 for Galileo."""
if not isinstance(dt, datetime):
return str(dt)
if dt.tzinfo is None:
# LiteLLM often passes naive datetimes in local time; convert to UTC
# instead of appending Z to local time (which shifts Traces tab sorting).
dt = dt.replace(tzinfo=GalileoObserve._local_timezone())
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@staticmethod
def _normalize_created_at(created_at: str) -> str:
if created_at and not re.search(r"(Z|[+-]\d{2}:?\d{2})$", created_at):
created_at = f"{created_at}Z"
return f"{created_at}Z"
return created_at
@staticmethod
def _token_metrics_from_record(record: Dict[str, Any]) -> Dict[str, Any]:
num_input_tokens = int(record.get("num_input_tokens") or 0)
num_output_tokens = int(record.get("num_output_tokens") or 0)
num_total_tokens = int(record.get("num_total_tokens") or 0)
if num_total_tokens == 0 and (num_input_tokens or num_output_tokens):
num_total_tokens = num_input_tokens + num_output_tokens
metrics: Dict[str, Any] = {
"num_input_tokens": num_input_tokens,
"num_output_tokens": num_output_tokens,
"num_total_tokens": num_total_tokens,
}
cost = record.get("cost")
if cost is not None:
metrics["cost"] = float(cost)
return metrics
@staticmethod
def _record_to_v2_span(
record: Dict[str, Any],
*,
trace_id: str,
span_id: str,
) -> Dict[str, Any]:
created_at = GalileoObserve._normalize_created_at(record.get("created_at", ""))
span: Dict[str, Any] = {
"type": "llm",
"id": span_id,
"trace_id": trace_id,
"parent_id": trace_id,
"name": record.get("node_type", "litellm"),
"created_at": created_at,
"input": GalileoObserve._galileo_input_messages(
@ -167,14 +231,49 @@ class GalileoObserve(CustomLogger):
"model": record.get("model"),
"metrics": {
"duration_ns": int(record.get("latency_ms", 0)) * 1_000_000,
"num_input_tokens": record.get("num_input_tokens"),
"num_output_tokens": record.get("num_output_tokens"),
**GalileoObserve._token_metrics_from_record(record),
},
}
if record.get("tags"):
span["tags"] = record["tags"]
return span
@staticmethod
def _record_to_v2_trace(record: Dict[str, Any]) -> Dict[str, Any]:
trace_id = str(uuid.uuid4())
span_id = str(uuid.uuid4())
created_at = GalileoObserve._normalize_created_at(record.get("created_at", ""))
return {
"type": "trace",
"id": trace_id,
"name": record.get("node_type", "litellm"),
"created_at": created_at,
"input": record.get("input_text", ""),
"output": record.get("output_text", ""),
"status_code": record.get("status_code", 200),
"metrics": {
"duration_ns": int(record.get("latency_ms", 0)) * 1_000_000,
**GalileoObserve._token_metrics_from_record(record),
},
"spans": [
GalileoObserve._record_to_v2_span(
record, trace_id=trace_id, span_id=span_id
)
],
}
def _build_traces_payload(self, records: List[dict]) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"traces": [self._record_to_v2_trace(record) for record in records],
"logging_method": "api_direct",
"reliable": False,
"is_complete": True,
}
if self.log_stream_id:
payload["log_stream_id"] = self.log_stream_id
return payload
def _get_ingest_request(self) -> Optional[Tuple[str, Dict[str, Any]]]:
if not self.base_url or not self.project_id:
return None
@ -184,105 +283,457 @@ class GalileoObserve(CustomLogger):
# flush_in_memory_records) aren't silently dropped when we later clear
# the in-memory buffer.
records = list(self.in_memory_records)
payload = self._build_traces_payload(records)
if self.use_v2_api:
payload: Dict[str, Any] = {
"spans": [self._record_to_v2_span(record) for record in records],
"reliable": False,
}
if self.log_stream_id:
payload["log_stream_id"] = self.log_stream_id
return (
f"{self.base_url}/v2/projects/{self.project_id}/spans",
f"{self.base_url}/ingest/traces/{self.project_id}",
payload,
)
# Username/password auth logs in for a JWT and uses the standard v2 traces API.
return (
f"{self.base_url}/projects/{self.project_id}/observe/ingest",
{"records": records},
f"{self.base_url}/v2/projects/{self.project_id}/traces",
payload,
)
@staticmethod
def _redact_headers(headers: Optional[Dict[str, str]]) -> Dict[str, str]:
if not headers:
return {}
redacted: Dict[str, str] = {}
for key, value in headers.items():
if key.lower() in {"authorization", "galileo-api-key"} and value:
redacted[key] = (
f"{value[:8]}...{value[-4:]}" if len(value) > 12 else "***"
)
else:
redacted[key] = value
return redacted
def _log_flush_config(self) -> None:
verbose_logger.debug(
"Galileo Logger flush config: use_v2_api=%s base_url=%s project_id=%s "
"log_stream_id=%s api_key_set=%s username_set=%s record_count=%s",
self.use_v2_api,
self.base_url,
self.project_id,
self.log_stream_id,
bool(self.api_key),
bool(self.username),
len(self.in_memory_records),
)
@staticmethod
def _log_v2_payload_validation(payload: Dict[str, Any]) -> None:
missing_fields: List[str] = []
traces = payload.get("traces", [])
if not traces:
missing_fields.append("traces")
for trace_index, trace in enumerate(traces):
if not isinstance(trace, dict):
continue
for field in ("id", "type", "spans"):
if field not in trace:
missing_fields.append(f"traces[{trace_index}].{field}")
trace_id = trace.get("id")
for span_index, span in enumerate(trace.get("spans", [])):
if not isinstance(span, dict):
continue
for field in ("id", "trace_id", "parent_id"):
if field not in span:
missing_fields.append(
f"traces[{trace_index}].spans[{span_index}].{field}"
)
if trace_id and span.get("trace_id") != trace_id:
missing_fields.append(
f"traces[{trace_index}].spans[{span_index}].trace_id mismatch"
)
if missing_fields:
verbose_logger.debug(
"Galileo Logger: ingest /traces payload validation issues: %s",
missing_fields,
)
def _log_flush_payload(self, url: str, payload: Dict[str, Any]) -> None:
traces = payload.get("traces", [])
verbose_logger.debug(
"Galileo Logger flush URL: %s trace_count=%s",
url,
len(traces) if isinstance(traces, list) else 0,
)
if self.use_v2_api and "/ingest/traces/" in url:
self._log_v2_payload_validation(payload)
@staticmethod
def _log_http_status_error(error: httpx.HTTPStatusError, url: str) -> None:
response = error.response
verbose_logger.debug(
"Galileo Logger HTTP error: status=%s url=%s",
response.status_code,
url,
)
verbose_logger.debug(
"Galileo Logger HTTP error response body: %s",
response.text,
)
try:
verbose_logger.debug(
"Galileo Logger HTTP error response json: %s",
response.json(),
)
except Exception:
pass
@staticmethod
def _build_prompt(kwargs: Dict[str, Any]) -> Dict[str, Any]:
optional_params = kwargs.get("optional_params", {}) or {}
prompt: Dict[str, Any] = {"messages": kwargs.get("messages")}
if optional_params.get("functions") is not None:
prompt["functions"] = optional_params["functions"]
if optional_params.get("tools") is not None:
prompt["tools"] = optional_params["tools"]
return prompt
@staticmethod
def _serialize_galileo_output(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
return value
def _json_default(obj: Any) -> Any:
if hasattr(obj, "model_dump"):
return obj.model_dump()
return str(obj)
return json.dumps(value, default=_json_default)
@staticmethod
def _prompt_to_input_text(prompt: Dict[str, Any]) -> str:
messages = prompt.get("messages")
if messages is not None:
text = GalileoObserve._input_text_from_messages(messages)
if text:
return text
return json.dumps(prompt, default=str)
@staticmethod
def _get_chat_content_for_galileo(response_obj: litellm.ModelResponse) -> Any:
if response_obj.choices and len(response_obj.choices) > 0:
message = response_obj["choices"][0]["message"]
if hasattr(message, "json"):
message_json = message.json()
if isinstance(message_json, str):
return json.loads(message_json)
return message_json
return message
return None
@staticmethod
def _get_text_completion_content_for_galileo(
response_obj: litellm.TextCompletionResponse,
) -> Optional[str]:
if response_obj.choices and len(response_obj.choices) > 0:
return response_obj.choices[0].text
return None
@staticmethod
def _get_responses_api_content_for_galileo(
response_obj: ResponsesAPIResponse,
) -> Any:
if hasattr(response_obj, "output") and response_obj.output:
return response_obj.output
return None
@staticmethod
def _langfuse_style_rerank_prompt(kwargs: Dict[str, Any]) -> Dict[str, Any]:
"""Match Langfuse rerank input: prompt = {"messages": kwargs.get("messages")}."""
return {"messages": kwargs.get("messages")}
def _get_galileo_input_output_content(
self,
kwargs: Dict[str, Any],
response_obj: Any,
level: str = "DEFAULT",
status_message: Optional[str] = None,
) -> Tuple[str, Optional[str], Any]:
"""
Mirror Langfuse _get_langfuse_input_output_content for Galileo ingest.
Returns (input_text, output_text, messages_for_span). output_text None skips ingest.
"""
call_type = kwargs.get("call_type")
prompt = self._build_prompt(kwargs)
if (
level == "ERROR"
and status_message is not None
and isinstance(status_message, str)
):
return self._prompt_to_input_text(prompt), status_message, prompt
if response_obj is not None and (
call_type == "embedding"
or isinstance(response_obj, litellm.EmbeddingResponse)
):
return self._prompt_to_input_text(prompt), None, prompt
if response_obj is not None and isinstance(response_obj, litellm.ModelResponse):
output = self._get_chat_content_for_galileo(response_obj)
return (
self._prompt_to_input_text(prompt),
self._serialize_galileo_output(output),
kwargs.get("messages") or [],
)
if response_obj is not None and isinstance(
response_obj, HttpxBinaryResponseContent
):
return self._prompt_to_input_text(prompt), "speech-output", prompt
if response_obj is not None and isinstance(
response_obj, litellm.TextCompletionResponse
):
output = self._get_text_completion_content_for_galileo(response_obj)
return (
self._prompt_to_input_text(prompt),
self._serialize_galileo_output(output),
kwargs.get("messages") or [],
)
if response_obj is not None and isinstance(response_obj, litellm.ImageResponse):
output = response_obj.get("data", None)
return (
self._prompt_to_input_text(prompt),
self._serialize_galileo_output(output),
prompt,
)
if response_obj is not None and isinstance(
response_obj, litellm.TranscriptionResponse
):
output = response_obj.get("text", None)
return (
self._prompt_to_input_text(prompt),
self._serialize_galileo_output(output),
prompt,
)
if response_obj is not None and isinstance(
response_obj, litellm.RerankResponse
):
output = response_obj.results
rerank_prompt = self._langfuse_style_rerank_prompt(kwargs)
return (
json.dumps(rerank_prompt, default=str),
self._serialize_galileo_output(output),
rerank_prompt,
)
if response_obj is not None and isinstance(response_obj, ResponsesAPIResponse):
output = self._get_responses_api_content_for_galileo(response_obj)
return (
self._prompt_to_input_text(prompt),
self._serialize_galileo_output(output),
kwargs.get("messages") or [],
)
if (
call_type == "_arealtime"
and response_obj is not None
and isinstance(response_obj, list)
):
input_val = kwargs.get("input")
return (
self._serialize_galileo_output(input_val) or "",
self._serialize_galileo_output(response_obj),
input_val,
)
if (
call_type == "pass_through_endpoint"
and response_obj is not None
and isinstance(response_obj, dict)
):
output = response_obj.get("response", "")
return (
self._prompt_to_input_text(prompt),
self._serialize_galileo_output(output),
prompt,
)
if response_obj is not None and isinstance(response_obj, dict):
output = get_content_from_model_response(response_obj)
return (
self._prompt_to_input_text(prompt),
self._serialize_galileo_output(output),
kwargs.get("messages") or [],
)
return self._prompt_to_input_text(prompt), None, kwargs.get("messages") or []
def get_output_str_from_response(
self, response_obj: Any, kwargs: Dict[str, Any]
) -> Optional[str]:
if response_obj is None:
return None
if kwargs.get("call_type", None) == "embedding" or isinstance(
response_obj, litellm.EmbeddingResponse
):
return None
if isinstance(response_obj, litellm.TextCompletionResponse):
return response_obj.choices[0].text
if isinstance(response_obj, litellm.ImageResponse):
return json.dumps(response_obj["data"], default=str)
if isinstance(response_obj, (litellm.ModelResponse, dict)):
return get_content_from_model_response(response_obj)
return None
_, output_text, _ = self._get_galileo_input_output_content(
kwargs=kwargs, response_obj=response_obj
)
return output_text
@staticmethod
def _input_text_from_messages(messages: Any) -> str:
"""Return a plain-string summary of the input suitable for the trace-level input field."""
if isinstance(messages, str):
return messages
if not isinstance(messages, list):
return ""
# Use the last user/human message so the trace table shows the actual prompt
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if str(msg.get("role", "")).lower() in ("user", "human"):
content = msg.get("content") or ""
if isinstance(content, list):
content = " ".join(
b.get("text", "") if isinstance(b, dict) else str(b)
for b in content
)
if content:
return str(content)
# Fallback: first non-empty content of any role
for msg in messages:
if isinstance(msg, dict):
content = msg.get("content") or ""
if isinstance(content, list):
content = " ".join(
b.get("text", "") if isinstance(b, dict) else str(b)
for b in content
)
if content:
return str(content)
return ""
async def async_log_success_event(
self, kwargs: Any, response_obj: Any, start_time: Any, end_time: Any
):
verbose_logger.debug("On Async Success")
try:
await self._async_log_success_event_impl(
kwargs=kwargs,
response_obj=response_obj,
start_time=start_time,
end_time=end_time,
)
except Exception:
verbose_logger.exception(
"Galileo Logger: unexpected error in async_log_success_event"
)
async def _async_log_success_event_impl(
self, kwargs: Any, response_obj: Any, start_time: Any, end_time: Any
):
if not self._is_configured():
verbose_logger.debug(
"Galileo Logger: skipping flush — set GALILEO_PROJECT_ID and "
"either GALILEO_API_KEY (hosted) or GALILEO_USERNAME/GALILEO_PASSWORD "
"(enterprise Observe)."
"Galileo Logger: skipping — GALILEO_PROJECT_ID=%s GALILEO_API_KEY=%s GALILEO_BASE_URL=%s",
bool(self.project_id),
bool(self.api_key),
bool(self.base_url),
)
return
_latency_ms = int((end_time - start_time).total_seconds() * 1000)
_call_type = kwargs.get("call_type", "litellm")
input_text = litellm.utils.get_formatted_prompt(
data=kwargs, call_type=_call_type
slo: Optional[Dict[str, Any]] = kwargs.get("standard_logging_object")
if slo is None:
verbose_logger.debug(
"Galileo Logger: no standard_logging_object in kwargs, skipping"
)
return
_call_type: str = str(
slo.get("call_type") or kwargs.get("call_type") or "litellm"
)
_usage = response_obj.get("usage", {}) or {}
num_input_tokens = _usage.get("prompt_tokens", 0)
num_output_tokens = _usage.get("completion_tokens", 0)
input_text, output_text, messages = self._get_galileo_input_output_content(
kwargs=kwargs, response_obj=response_obj
)
if output_text is None:
verbose_logger.debug(
"Galileo Logger: skipping %s — no text output to log", _call_type
)
return
output_text = self.get_output_str_from_response(
response_obj=response_obj, kwargs=kwargs
raw_start = slo.get("startTime")
raw_end = slo.get("endTime")
if raw_start is None or raw_end is None:
verbose_logger.debug(
"Galileo Logger: standard_logging_object missing startTime/endTime, "
"falling back to start_time/end_time params"
)
if not isinstance(start_time, datetime) or not isinstance(
end_time, datetime
):
return
start_ts = start_time
end_ts = end_time
if start_ts.tzinfo is None:
start_ts = start_ts.replace(tzinfo=GalileoObserve._local_timezone())
if end_ts.tzinfo is None:
end_ts = end_ts.replace(tzinfo=GalileoObserve._local_timezone())
start_ts = start_ts.astimezone(timezone.utc)
end_ts = end_ts.astimezone(timezone.utc)
else:
start_ts = datetime.fromtimestamp(float(raw_start), tz=timezone.utc)
end_ts = datetime.fromtimestamp(float(raw_end), tz=timezone.utc)
_latency_ms = max(0, int((end_ts - start_ts).total_seconds() * 1000))
num_input_tokens = int(slo.get("prompt_tokens") or 0)
num_output_tokens = int(slo.get("completion_tokens") or 0)
num_total_tokens = int(slo.get("total_tokens") or 0)
if num_total_tokens == 0 and (num_input_tokens or num_output_tokens):
num_total_tokens = num_input_tokens + num_output_tokens
request_record = LLMResponse(
latency_ms=_latency_ms,
status_code=200,
input_text=input_text,
output_text=output_text,
node_type=_call_type,
model=str(slo.get("model") or kwargs.get("model") or "-"),
num_input_tokens=num_input_tokens,
num_output_tokens=num_output_tokens,
num_total_tokens=num_total_tokens,
cost=slo.get("response_cost"),
created_at=GalileoObserve._format_created_at(start_ts),
)
if output_text is not None:
request_record = LLMResponse(
latency_ms=_latency_ms,
status_code=200,
input_text=input_text,
output_text=output_text,
node_type=_call_type,
model=kwargs.get("model", "-"),
num_input_tokens=num_input_tokens,
num_output_tokens=num_output_tokens,
created_at=start_time.strftime(
"%Y-%m-%dT%H:%M:%S"
), # timestamp str constructed in "%Y-%m-%dT%H:%M:%S" format
request_dict = request_record.model_dump()
if isinstance(messages, dict):
messages = messages.get("messages")
if isinstance(messages, list) and messages:
request_dict["messages"] = messages
self.in_memory_records.append(request_dict)
verbose_logger.debug(
"Galileo Logger: queued record, in_memory=%d", len(self.in_memory_records)
)
# Bound the buffer so persistent flush failures cannot grow it
# without limit. Drop the oldest records once we exceed the cap.
if len(self.in_memory_records) > GALILEO_MAX_IN_MEMORY_RECORDS:
dropped = len(self.in_memory_records) - GALILEO_MAX_IN_MEMORY_RECORDS
self.in_memory_records = self.in_memory_records[
-GALILEO_MAX_IN_MEMORY_RECORDS:
]
verbose_logger.warning(
"Galileo Logger: in-memory buffer exceeded %s records; "
"dropped %s oldest record(s). Check Galileo connectivity/credentials.",
GALILEO_MAX_IN_MEMORY_RECORDS,
dropped,
)
request_dict = request_record.model_dump()
messages = kwargs.get("messages")
if messages:
request_dict["messages"] = messages
self.in_memory_records.append(request_dict)
# Bound the buffer so persistent flush failures cannot grow it
# without limit. Drop the oldest records once we exceed the cap.
if len(self.in_memory_records) > GALILEO_MAX_IN_MEMORY_RECORDS:
dropped = len(self.in_memory_records) - GALILEO_MAX_IN_MEMORY_RECORDS
self.in_memory_records = self.in_memory_records[
-GALILEO_MAX_IN_MEMORY_RECORDS:
]
verbose_logger.warning(
"Galileo Logger: in-memory buffer exceeded %s records; "
"dropped %s oldest record(s). Check Galileo connectivity/credentials.",
GALILEO_MAX_IN_MEMORY_RECORDS,
dropped,
)
if len(self.in_memory_records) >= self.batch_size:
await self.flush_in_memory_records()
if len(self.in_memory_records) >= self.batch_size:
await self.flush_in_memory_records()
async def flush_in_memory_records(self):
if not self.in_memory_records:
@ -296,15 +747,23 @@ class GalileoObserve(CustomLogger):
ingest_request = self._get_ingest_request()
if ingest_request is None:
verbose_logger.debug(
"Galileo Logger: missing GALILEO_BASE_URL or GALILEO_PROJECT_ID"
"Galileo Logger: missing GALILEO_BASE_URL or GALILEO_PROJECT_ID — skipping flush"
)
return
if not await self._ensure_headers():
verbose_logger.debug("Galileo Logger: could not set request headers")
verbose_logger.debug(
"Galileo Logger: could not set request headers — skipping flush"
)
return
url, payload = ingest_request
self._log_flush_config()
self._log_flush_payload(url=url, payload=payload)
verbose_logger.debug(
"Galileo Logger flush headers: %s",
self._redact_headers(self.headers),
)
verbose_logger.debug("flushing in memory records to %s", url)
try:
@ -313,6 +772,12 @@ class GalileoObserve(CustomLogger):
headers=self.headers,
json=payload,
)
except httpx.HTTPStatusError as e:
self._log_http_status_error(error=e, url=url)
verbose_logger.debug(
"Galileo Logger: failed to flush in memory records: %s", e
)
return
except Exception as e:
verbose_logger.debug(
"Galileo Logger: failed to flush in memory records: %s", e
@ -323,6 +788,11 @@ class GalileoObserve(CustomLogger):
verbose_logger.debug(
"Galileo Logger: successfully flushed in memory records"
)
verbose_logger.debug(
"Galileo Logger flush response: status=%s body=%s",
response.status_code,
response.text,
)
del self.in_memory_records[:records_in_payload]
else:
verbose_logger.debug("Galileo Logger: failed to flush in memory records")

View file

@ -102,6 +102,18 @@ def langfuse_client_init(
if Version(langfuse.version.__version__) >= Version("2.6.0"):
parameters["sdk_integration"] = "litellm"
if Version(langfuse.version.__version__) >= Version("2.7.3"):
import httpx
import litellm
from ...llms.custom_httpx.http_handler import get_ssl_configuration
parameters["httpx_client"] = httpx.Client(
verify=get_ssl_configuration(),
cert=os.getenv("SSL_CERTIFICATE", litellm.ssl_certificate),
)
client = Langfuse(**parameters)
return client

View file

@ -65,7 +65,15 @@ class OpenMeterLogger(CustomLogger):
"total_tokens": response_obj["usage"].get("total_tokens"),
}
user_param = kwargs.get("user", None) # end-user passed in via 'user' param
# OPENMETER_TRUST_REQUEST_USER (default "true"): when set to "false",
# the request-supplied `user` field is ignored and the subject is
# resolved solely from the key-bound user_api_key_user_id. Proxies
# serving multi-tenant traffic enable this to prevent clients from
# forging attribution by setting `user` in the request body.
trust_request_user = (
os.getenv("OPENMETER_TRUST_REQUEST_USER", "true").lower() != "false"
)
user_param = kwargs.get("user", None) if trust_request_user else None
# If no user provided directly, try to get it from token user_id
if user_param is None:

View file

@ -1012,6 +1012,13 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
litellm_params = kwargs.get("litellm_params", {}) or {}
_metadata = litellm_params.get("metadata", {}) or {}
proxy_span = _metadata.get("litellm_parent_otel_span", None)
# Fallback: check litellm_metadata (used by /v1/messages and other
# LITELLM_METADATA_ROUTES).
if proxy_span is None:
_litellm_metadata = litellm_params.get("litellm_metadata", {}) or {}
proxy_span = _litellm_metadata.get("litellm_parent_otel_span", None)
if (
proxy_span is not None
and getattr(proxy_span, "name", None) == LITELLM_PROXY_REQUEST_SPAN_NAME
@ -2668,6 +2675,10 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
)
def _to_ns(self, dt):
if dt is None:
return int(datetime.now().timestamp() * 1e9)
if isinstance(dt, (int, float)):
return int(dt * 1e9)
return int(dt.timestamp() * 1e9)
def _get_span_name(self, kwargs):
@ -2714,6 +2725,13 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
_metadata = litellm_params.get("metadata", {}) or {}
parent_otel_span = _metadata.get("litellm_parent_otel_span", None)
# Fallback: check litellm_metadata (used by /v1/messages and other
# LITELLM_METADATA_ROUTES that store proxy-internal metadata
# separately from the provider's native "metadata" field).
if parent_otel_span is None:
_litellm_metadata = litellm_params.get("litellm_metadata", {}) or {}
parent_otel_span = _litellm_metadata.get("litellm_parent_otel_span", None)
# Priority 1: Explicit parent span from metadata
if parent_otel_span is not None:
verbose_logger.debug(

View file

@ -39,20 +39,32 @@ def extract_opik_metadata(
standard_logging_metadata: Dict[str, Any],
) -> Dict[str, Any]:
"""
Extract and merge Opik metadata from request and requester.
Merge Opik metadata from three sources in increasing priority order:
1. user_api_key_auth_metadata lowest priority (operator-level defaults)
2. litellm_metadata (request) overrides auth-key defaults
3. requester_metadata highest priority (e.g. proxy header overrides)
Args:
litellm_metadata: Metadata from litellm_params
standard_logging_metadata: Metadata from standard_logging_object
litellm_metadata: Metadata from litellm_params.mak
standard_logging_metadata: Metadata from standard_logging_object.
Returns:
Merged Opik metadata dictionary
Merged Opik metadata dictionary.
"""
opik_meta = litellm_metadata.get("opik", {}).copy()
# Start with auth-key defaults (lowest priority).
auth_meta = standard_logging_metadata.get("user_api_key_auth_metadata") or {}
opik_meta = (auth_meta.get("opik") or {}).copy()
# Request-level values override auth-key defaults.
request_opik = litellm_metadata.get("opik") or {}
opik_meta.update(request_opik)
# Requester-level values win over everything else.
requester_metadata = standard_logging_metadata.get("requester_metadata", {}) or {}
requester_opik = requester_metadata.get("opik", {}) or {}
opik_meta.update(requester_opik)
if requester_opik:
opik_meta.update(requester_opik)
_logging.verbose_logger.debug(
f"litellm_opik_metadata - {json.dumps(opik_meta, default=str)}"

View file

@ -15,6 +15,7 @@ from litellm.integrations.otel.model.baggage import promoted_baggage
from litellm.integrations.otel.model.config import OpenTelemetryV2Config
from litellm.integrations.otel.plumbing.context import (
is_recordable_span,
request_root_span,
resolve_parent_context,
resolve_request_span_context,
set_request_baggage,
@ -25,7 +26,6 @@ from litellm.integrations.otel.mappers import resolve_mappers
from litellm.integrations.otel.model.metadata import (
LLMCallEvent,
RequestIdentity,
guardrail_entries_from_request_data,
model_from_request_data,
)
from litellm.integrations.otel.model.payloads import (
@ -436,8 +436,12 @@ class OpenTelemetryV2(CustomLogger):
attach(set_request_baggage(bag, context=get_current()))
# The server span was started by the instrumentor before this ran,
# so the Baggage processor (which only fires at span start) won't
# backfill it — stamp identity on it directly.
server_span = get_current_span()
# backfill it — stamp identity on it directly. Prefer the anchored
# root span over the ambient one so identity still lands on the
# server span when seeding from inside the live ``auth`` phase span
# (the auth-failure path), where ``get_current_span`` is the phase
# span, not the request's root.
server_span = request_root_span() or get_current_span()
if is_recordable_span(server_span):
# Re-capture the anchor here too: this runs post-auth with the
# server span active and covers entrypoints that bypass
@ -468,46 +472,27 @@ class OpenTelemetryV2(CustomLogger):
)
return data
async def async_post_call_success_hook(
self,
data: Mapping[str, Any],
user_api_key_dict: Any,
response: Any,
) -> Any:
self._emit_guardrail_spans(data)
return response
async def async_post_call_failure_hook(
self,
request_data: Mapping[str, Any],
original_exception: BaseException | None,
user_api_key_dict: Any,
traceback_str: str | None = None,
) -> None:
self._emit_guardrail_spans(request_data)
def _emit_guardrail_spans(self, request_data: Mapping[str, Any]) -> None:
def emit_guardrail_span(self, entry: "StandardLoggingGuardrailInformation") -> None:
# Emitted by the guardrail-recording code the moment a guardrail finishes,
# not from a post-call hook — that hook does not fire on every path (a
# pass-through request that passes its guardrails never reaches it), which
# left passing guardrails without a span.
#
# A guardrail is a sibling of the LLM call under the request's root span,
# so parent it to the explicit anchor — not the active span, which on the
# failure path can be the live ``auth`` phase span (post-call failure hooks
# run from inside it on an auth rejection). Emit with the guardrail's actual
# execution window so a pre_call guardrail is placed before the LLM call
# rather than at post-call emission time.
guardrails = guardrail_entries_from_request_data(request_data)
if not guardrails:
return
parent_ctx = resolve_request_span_context()
for entry in guardrails:
data = GuardrailSpanData.from_logging_entry(
cast("StandardLoggingGuardrailInformation", entry)
)
self._emitter.emit(
SpanRole.GUARDRAIL,
data,
parent_context=parent_ctx,
start_time_ns=to_ns(data.start_time),
end_time_ns=to_ns(data.end_time),
)
# so parent it to the explicit anchor — never the active span, which during
# a pre_call guardrail can be the live ``auth`` phase span. Emit with the
# guardrail's actual execution window so a pre_call guardrail is placed
# before the LLM call rather than at emission time. One entry in, one span
# out — the module-level entry point routes each entry to this single
# registered logger so a guardrail is never emitted more than once.
data = GuardrailSpanData.from_logging_entry(entry)
self._emitter.emit(
SpanRole.GUARDRAIL,
data,
parent_context=resolve_request_span_context(),
start_time_ns=to_ns(data.start_time),
end_time_ns=to_ns(data.end_time),
)
def create_litellm_proxy_request_started_span(
self, start_time: datetime, headers: Mapping[str, str] | None
@ -528,6 +513,26 @@ def _registered_v2_logger() -> "OpenTelemetryV2 | None":
return logger if isinstance(logger, OpenTelemetryV2) else None
def emit_guardrail_span(entry: "StandardLoggingGuardrailInformation") -> None:
"""Emit a guardrail span on the registered v2 OTel logger.
Called by the guardrail-recording code the moment a guardrail finishes, so a
span is produced regardless of whether a post-call hook later runs (it does
not on the pass-through allow path). Routes through the single canonical
logger the same one every other v2 entry point uses so a guardrail
recorded once yields exactly one span; fanning out across every reachable
``OpenTelemetryV2`` instance double-emits the same entry. Best-effort: span
emission must never break guardrail evaluation.
"""
logger = _registered_v2_logger()
if logger is None:
return
try:
logger.emit_guardrail_span(entry)
except Exception:
pass
def seed_request_identity(user_api_key_dict: Any, model: Any = None) -> None:
logger = _registered_v2_logger()
if logger is not None:

View file

@ -255,26 +255,6 @@ def model_from_request_data(data: object) -> str | None:
return None
def guardrail_entries_from_request_data(
request_data: Mapping[str, Any],
) -> list[dict]:
"""The guardrail-information dicts buried in ``metadata`` of a post-call dict.
``standard_logging_guardrail_information`` is stored as either a single dict
or a list of them; normalize to a list of dicts (dropping non-dict noise) so
the caller just iterates. Empty list when none are present.
"""
metadata = request_data.get("metadata")
if not isinstance(metadata, Mapping):
return []
info = metadata.get("standard_logging_guardrail_information")
if isinstance(info, Mapping):
return [cast(dict, info)]
if isinstance(info, list):
return [entry for entry in info if isinstance(entry, dict)]
return []
def resolve_provider_model(payload: "StandardLoggingPayload") -> str | None:
"""The model litellm dispatched to the provider, from the payload.

View file

@ -340,7 +340,7 @@ class MCPToolCallSpanData:
def from_standard_logging_payload(
cls, payload: "StandardLoggingPayload", capture_content: bool = False
) -> "MCPToolCallSpanData":
meta = cast(Mapping[str, object], payload.get("mcp_tool_call_metadata") or {})
meta = _mcp_tool_call_metadata(cast(Mapping[str, object], payload))
return cls(
operation=resolve_operation(as_str(payload.get("call_type"))),
method=MCPMethod.TOOLS_CALL.value,
@ -363,11 +363,22 @@ class MCPToolCallSpanData:
)
def _mcp_tool_call_metadata(payload: Mapping[str, object]) -> Mapping[str, object]:
"""The MCP gateway's tool-call metadata, which lives under
``StandardLoggingPayload.metadata`` (a ``StandardLoggingMetadata`` key), not
at the payload's top level."""
metadata = payload.get("metadata")
if not isinstance(metadata, Mapping):
return {}
meta = metadata.get("mcp_tool_call_metadata")
return meta if isinstance(meta, Mapping) else {}
def is_mcp_tool_call(payload: Mapping[str, object]) -> bool:
"""Whether a closed request's payload is an MCP tool call rather than an LLM
call true when the MCP gateway stamped its tool-call metadata, or the call
type says so on a path that hasn't populated the metadata yet."""
return bool(payload.get("mcp_tool_call_metadata")) or (
return bool(_mcp_tool_call_metadata(payload)) or (
payload.get("call_type") == "call_mcp_tool"
)

View file

@ -24,14 +24,18 @@ from typing import (
import litellm
from litellm._logging import print_verbose, verbose_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.prometheus_helpers.bounded_prometheus_series_tracker import (
BoundedPrometheusSeriesTracker,
from litellm.exceptions import (
validate_rate_limit_category,
validate_rate_limit_type,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.prometheus_helpers import (
PrometheusLabelFactoryContext,
_get_cached_end_user_id_for_cost_tracking,
)
from litellm.integrations.prometheus_helpers.bounded_prometheus_series_tracker import (
BoundedPrometheusSeriesTracker,
)
from litellm.litellm_core_utils.core_helpers import (
get_litellm_metadata_from_kwargs,
get_metadata_variable_name_from_kwargs,
@ -42,6 +46,9 @@ from litellm.proxy._types import (
LiteLLM_UserTable,
UserAPIKeyAuth,
)
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.types.integrations.prometheus import *
from litellm.types.integrations.prometheus import (
_sanitize_prometheus_label_name,
@ -78,6 +85,20 @@ class PrometheusLogger(CustomLogger):
# Always initialize label_filters, even for non-premium users
self.label_filters = self._parse_prometheus_config()
# Cache resolved label sets per metric. Several entries in
# ``PrometheusMetricLabels.get_labels`` read module-level toggles
# (e.g. ``litellm.prometheus_emit_stream_label``,
# ``litellm.prometheus_emit_rate_limit_labels``) that can be
# changed at runtime. Prometheus counters/gauges/histograms are
# created with a *fixed* ``labelnames`` set; if a runtime call
# to ``get_labels_for_metric`` returned a different set, the
# subsequent ``counter.labels(**_labels)`` would raise a
# ``ValueError`` from the prometheus client. Snapshotting at
# logger init time pins the label set for the lifetime of the
# logger so toggling these flags only takes effect after a
# restart, keeping init-time and runtime label sets in sync.
self._cached_metric_labels: Dict[str, List[str]] = {}
_custom_buckets = litellm.prometheus_latency_buckets
self.latency_buckets = (
tuple(_custom_buckets)
@ -1033,13 +1054,27 @@ class PrometheusLogger(CustomLogger):
self, metric_name: DEFINED_PROMETHEUS_METRICS
) -> List[str]:
"""
Get the labels for a metric, filtered if configured
Get the labels for a metric, filtered if configured.
The result is cached on the instance so the label set used to
construct each Prometheus metric at ``__init__`` time stays in lock
step with the label set passed to ``counter.labels(...)`` at
runtime, even if the underlying module-level toggles consulted by
:meth:`PrometheusMetricLabels.get_labels` (e.g.
``litellm.prometheus_emit_rate_limit_labels``,
``litellm.prometheus_emit_stream_label``) are flipped after the
logger has been created.
"""
cached = self._cached_metric_labels.get(metric_name)
if cached is not None:
return cached
# Get default labels for this metric from PrometheusMetricLabels
default_labels = PrometheusMetricLabels.get_labels(metric_name)
# If no label filtering is configured for this metric, use default labels
if metric_name not in self.label_filters:
self._cached_metric_labels[metric_name] = default_labels
return default_labels
# Get configured labels for this metric
@ -1050,6 +1085,7 @@ class PrometheusLogger(CustomLogger):
label for label in default_labels if label in configured_labels
]
self._cached_metric_labels[metric_name] = filtered_labels
return filtered_labels
def _track_end_user_metric_series(
@ -2029,14 +2065,8 @@ class PrometheusLogger(CustomLogger):
Proxy level tracking - failed client side requests
labelnames=[
"end_user",
"hashed_api_key",
"api_key_alias",
REQUESTED_MODEL,
"team",
"team_alias",
] + EXCEPTION_LABELS,
See :attr:`PrometheusMetricLabels.litellm_proxy_failed_requests_metric`
for the authoritative list of labels emitted on this metric.
"""
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
@ -2059,6 +2089,9 @@ class PrometheusLogger(CustomLogger):
model_id = _metadata.get("model_info", {}).get("id") or request_data.get(
"model_info", {}
).get("id")
rate_limit_category, rate_limit_type = self._extract_rate_limit_labels(
original_exception
)
enum_values = UserAPIKeyLabelValues(
end_user=user_api_key_dict.end_user_id,
user=user_api_key_dict.user_id,
@ -2073,6 +2106,8 @@ class PrometheusLogger(CustomLogger):
status_code=str(status_code),
exception_status=str(status_code),
exception_class=self._get_exception_class_name(original_exception),
rate_limit_category=rate_limit_category,
rate_limit_type=rate_limit_type,
tags=_tags,
route=user_api_key_dict.request_route,
client_ip=_metadata.get("requester_ip_address"),
@ -2690,7 +2725,7 @@ class PrometheusLogger(CustomLogger):
Args:
guardrail_name: Name of the guardrail
latency_seconds: Execution latency in seconds
status: "success" or "error"
status: "success", "error", or "intervened"
error_type: Type of error if any, None otherwise
hook_type: "pre_call", "during_call", or "post_call"
"""
@ -2843,6 +2878,33 @@ class PrometheusLogger(CustomLogger):
@staticmethod
def _get_exception_class_name(exception: Exception) -> str:
# Some exception types pin the ``exception_class`` label to a legacy
# value for back-compat with existing dashboards (e.g. proxy-side 429s
# keep reporting as "HTTPException"). Honor that opt-in marker before
# deriving the label from the runtime class name. Reading it via
# ``getattr`` keeps this core integrations module free of a transitive
# ``fastapi`` dependency.
legacy_class_name = getattr(exception, "prometheus_exception_class_name", None)
if isinstance(legacy_class_name, str) and legacy_class_name:
return legacy_class_name
# Same back-compat reasoning for ``BudgetExceededError``: the unified
# rate-limit error work attached ``.llm_provider`` to budget errors
# too (so callbacks reading ``StandardLoggingPayload`` get provider
# attribution). Without this short-circuit, the provider prefix below
# would silently flip the label from "BudgetExceededError" to e.g.
# "Openai.BudgetExceededError" and break dashboards keyed on the
# original value.
try:
from litellm.exceptions import BudgetExceededError
except ImportError:
BudgetExceededError = None # type: ignore[assignment,misc]
if BudgetExceededError is not None and isinstance(
exception, BudgetExceededError
):
return "BudgetExceededError"
exception_class_name = ""
if hasattr(exception, "llm_provider"):
exception_class_name = getattr(exception, "llm_provider") or ""
@ -2857,6 +2919,27 @@ class PrometheusLogger(CustomLogger):
exception_class_name += exception.__class__.__name__
return exception_class_name
@staticmethod
def _extract_rate_limit_labels(
exception: Optional[Exception],
) -> Tuple[Optional[str], Optional[str]]:
"""
Pull the unified ``category`` / ``rate_limit_type`` fields off any
exception that declares them (``litellm.RateLimitError`` and bare-
Exception subclasses like ``BudgetExceededError``).
Values are validated against the :class:`RateLimitErrorCategory` /
:class:`RateLimitType` enums so unrelated third-party exceptions that
happen to declare ``.category`` / ``.rate_limit_type`` string attributes
can't leak garbage into Prometheus label cardinality.
"""
if exception is None:
return None, None
return (
validate_rate_limit_category(getattr(exception, "category", None)),
validate_rate_limit_type(getattr(exception, "rate_limit_type", None)),
)
async def log_success_fallback_event(
self, original_model_group: str, kwargs: dict, original_exception: Exception
):
@ -3198,12 +3281,12 @@ class PrometheusLogger(CustomLogger):
page_size: int, page: int
) -> Tuple[List[LiteLLM_UserTable], Optional[int]]:
skip = (page - 1) * page_size
users = await prisma_client.db.litellm_usertable.find_many(
users = await UserRepository(prisma_client).table.find_many(
skip=skip,
take=page_size,
order={"created_at": "desc"},
)
total_count = await prisma_client.db.litellm_usertable.count()
total_count = await UserRepository(prisma_client).table.count()
return users, total_count
await self._initialize_budget_metrics(
@ -3226,13 +3309,13 @@ class PrometheusLogger(CustomLogger):
async def fetch_orgs(page_size: int, page: int) -> Tuple[list, Optional[int]]:
skip = (page - 1) * page_size
orgs = await prisma_client.db.litellm_organizationtable.find_many(
orgs = await OrganizationRepository(prisma_client).table.find_many(
skip=skip,
take=page_size,
order={"created_at": "desc"},
include={"litellm_budget_table": True},
)
total_count = await prisma_client.db.litellm_organizationtable.count()
total_count = await OrganizationRepository(prisma_client).table.count()
return orgs, total_count
await self._initialize_budget_metrics(
@ -3300,14 +3383,14 @@ class PrometheusLogger(CustomLogger):
try:
# Get total user count
total_users = await prisma_client.db.litellm_usertable.count()
total_users = await UserRepository(prisma_client).table.count()
self.litellm_total_users_metric.set(total_users)
verbose_logger.debug(
f"Prometheus: set litellm_total_users to {total_users}"
)
# Get total team count
total_teams = await prisma_client.db.litellm_teamtable.count()
total_teams = await TeamRepository(prisma_client).table.count()
self.litellm_teams_count_metric.set(total_teams)
verbose_logger.debug(
f"Prometheus: set litellm_teams_count to {total_teams}"

View file

@ -244,6 +244,9 @@ search_tools:
- search_tool_name: "my-tavily-tool"
litellm_params:
search_provider: "tavily"
- search_tool_name: "my-you-com-tool"
litellm_params:
search_provider: "you_com"
```
---

View file

@ -655,7 +655,11 @@ def exception_type( # type: ignore # noqa: PLR0915
custom_llm_provider == "anthropic"
or custom_llm_provider == "anthropic_text"
): # one of the anthropics
if "prompt is too long" in error_str or "prompt: length" in error_str:
if (
"prompt is too long" in error_str
or "prompt: length" in error_str
or ExceptionCheckers.is_error_str_context_window_exceeded(error_str)
):
exception_mapping_worked = True
raise ContextWindowExceededError(
message="AnthropicError - {}".format(error_str),

View file

@ -373,6 +373,9 @@ def get_llm_provider( # noqa: PLR0915
elif endpoint == "https://api.lambda.ai/v1":
custom_llm_provider = "lambda_ai"
dynamic_api_key = get_secret_str("LAMBDA_API_KEY")
elif endpoint == "https://api.inceptionlabs.ai/v1":
custom_llm_provider = "inception"
dynamic_api_key = get_secret_str("INCEPTION_API_KEY")
elif endpoint == "https://api.hyperbolic.xyz/v1":
custom_llm_provider = "hyperbolic"
dynamic_api_key = get_secret_str("HYPERBOLIC_API_KEY")
@ -656,6 +659,11 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915
or get_secret_str("NVIDIA_RIVA_API_KEY")
or get_secret_str("NVIDIA_NIM_API_KEY")
)
elif custom_llm_provider == "soniox":
api_base = (
api_base or get_secret_str("SONIOX_API_BASE") or "https://api.soniox.com"
)
dynamic_api_key = api_key or get_secret_str("SONIOX_API_KEY")
elif custom_llm_provider == "cerebras":
api_base = (
api_base or get_secret("CEREBRAS_API_BASE") or "https://api.cerebras.ai/v1"
@ -954,6 +962,13 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915
) = litellm.LambdaAIChatConfig()._get_openai_compatible_provider_info(
api_base, api_key
)
elif custom_llm_provider == "inception":
(
api_base,
dynamic_api_key,
) = litellm.InceptionChatConfig()._get_openai_compatible_provider_info(
api_base, api_key
)
elif custom_llm_provider == "hyperbolic":
(
api_base,

View file

@ -22,9 +22,11 @@ def get_supported_openai_params( # noqa: PLR0915
```
Args:
base_model: For Azure, the true underlying model (e.g. ``"azure/gpt-5.2"``)
when the deployment name differs. Used for model-type detection so that
non-standard deployment names route to the correct config.
base_model: An optional capability hint for deployments whose ``model``
label isn't recognized on its own (e.g. an Azure deployment name, or a
friendly Bedrock alias). It is additive: the result is the union of the
params supported by ``model`` and by ``base_model``, so a hint can only
add capabilities, never strip ones the real model already supports.
Returns:
- List if custom_llm_provider is mapped
@ -52,7 +54,15 @@ def get_supported_openai_params( # noqa: PLR0915
provider_config = None
if provider_config and request_type == "chat_completion":
return provider_config.get_supported_openai_params(model=base_model or model)
supported_params = provider_config.get_supported_openai_params(model=model)
if base_model and base_model != model:
base_model_params = provider_config.get_supported_openai_params(
model=base_model
)
supported_params = list(
dict.fromkeys([*supported_params, *base_model_params])
)
return supported_params
if custom_llm_provider == "bedrock":
return litellm.AmazonConverseConfig().get_supported_openai_params(model=model)
@ -331,6 +341,11 @@ def get_supported_openai_params( # noqa: PLR0915
return ElevenLabsAudioTranscriptionConfig().get_supported_openai_params(
model=model
)
elif custom_llm_provider == "soniox":
if request_type == "transcription":
return litellm.SonioxAudioTranscriptionConfig().get_supported_openai_params(
model=model
)
elif custom_llm_provider in litellm._custom_providers:
if request_type == "chat_completion":
provider_config = litellm.ProviderConfigManager.get_provider_chat_config(

View file

@ -37,6 +37,10 @@ from litellm import (
turn_off_message_logging,
)
from litellm._logging import _is_debugging_on, _redact_string, verbose_logger
from litellm.exceptions import (
validate_rate_limit_category,
validate_rate_limit_type,
)
from litellm._uuid import uuid
from litellm.batches.batch_utils import _handle_completed_batch
from litellm.caching.caching import DualCache, InMemoryCache
@ -3503,7 +3507,9 @@ class Logging(LiteLLMLoggingBaseClass):
else:
return None
def _handle_anthropic_messages_response_logging(self, result: Any) -> ModelResponse:
def _handle_anthropic_messages_response_logging(
self, result: Any
) -> Union[ModelResponse, ResponsesAPIResponse]:
"""
Handles logging for Anthropic messages responses.
@ -3522,6 +3528,15 @@ class Logging(LiteLLMLoggingBaseClass):
return result
elif isinstance(result, ModelResponse):
return result
elif isinstance(
result,
(ResponseCompletedEvent, ResponseIncompleteEvent, ResponseFailedEvent),
):
# anthropic_messages() can route to OpenAI Responses API; in that path
# the assembled streaming result is one of these terminal events rather than
# a ModelResponse. Return the inner response so downstream handlers
# (_transform_usage_objects, normalize_logging_result) can process it.
return result.response
httpx_response = self.model_call_details.get("httpx_response", None)
if httpx_response and isinstance(httpx_response, httpx.Response):
@ -5307,12 +5322,27 @@ class StandardLoggingPayloadSetup:
else str(original_exception)
)
# Duck-typed read so bare-Exception subclasses like
# `litellm.BudgetExceededError` can participate without joining the
# RateLimitError hierarchy (which would break `except BudgetExceededError`).
# Validated against the enum value sets so a third-party exception that
# happens to declare a `.category` or `.rate_limit_type` string attribute
# can't leak garbage into the payload or Prometheus label cardinality.
rate_limit_category = validate_rate_limit_category(
getattr(original_exception, "category", None)
)
rate_limit_type = validate_rate_limit_type(
getattr(original_exception, "rate_limit_type", None)
)
return StandardLoggingPayloadErrorInformation(
error_code=error_status,
error_class=error_class,
llm_provider=_llm_provider_in_exception,
traceback=traceback_info,
error_message=error_message if original_exception else "",
error_rate_limit_category=rate_limit_category,
error_rate_limit_type=rate_limit_type,
)
@staticmethod

View file

@ -34,6 +34,14 @@ _IMAGE_RESPONSE_CALL_TYPES = frozenset(
_VALID_DATA_RESIDENCIES = frozenset(r.value for r in DataResidency)
def _get_token_detail_value(details: object, key: str) -> Optional[int]:
if isinstance(details, dict):
value = details.get(key)
else:
value = getattr(details, key, None)
return value if isinstance(value, int) else None
def _is_above_128k(tokens: float) -> bool:
if tokens > 128000:
return True
@ -870,17 +878,47 @@ def calculate_image_response_cost_from_usage(
cached_tokens=0,
)
output_tokens_details = getattr(usage, "completion_tokens_details", None)
if output_tokens_details is None:
output_tokens_details = getattr(usage, "output_tokens_details", None)
if output_tokens_details is None:
completion_tokens_details = CompletionTokensDetailsWrapper(
text_tokens=0,
image_tokens=completion_tokens,
reasoning_tokens=0,
audio_tokens=0,
)
else:
text_tokens = _get_token_detail_value(output_tokens_details, "text_tokens") or 0
image_tokens = (
_get_token_detail_value(output_tokens_details, "image_tokens") or 0
)
audio_tokens = (
_get_token_detail_value(output_tokens_details, "audio_tokens") or 0
)
reasoning_tokens = (
_get_token_detail_value(output_tokens_details, "reasoning_tokens") or 0
)
known_output_tokens = (
text_tokens + image_tokens + audio_tokens + reasoning_tokens
)
if completion_tokens > known_output_tokens:
text_tokens += completion_tokens - known_output_tokens
completion_tokens_details = CompletionTokensDetailsWrapper(
text_tokens=text_tokens,
image_tokens=image_tokens,
reasoning_tokens=reasoning_tokens,
audio_tokens=audio_tokens,
)
normalized_usage = Usage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
prompt_tokens_details=prompt_tokens_details,
completion_tokens_details=CompletionTokensDetailsWrapper(
text_tokens=0,
image_tokens=completion_tokens,
reasoning_tokens=0,
audio_tokens=0,
),
completion_tokens_details=completion_tokens_details,
)
prompt_cost, completion_cost = generic_cost_per_token(

View file

@ -1670,15 +1670,15 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915
if gemini_call_id:
_function_response["id"] = gemini_call_id
# Create part with function_response, and optionally inline_data for images (Computer Use)
_part: VertexPartType = {"function_response": _function_response}
# For Computer Use, if we have images/files, we need separate parts:
# - One part with function_response
# - One part per inline_data item
# Gemini's PartType is a oneof, so we can't have both in the same part
# For multimodal function responses, Gemini expects media parts nested
# inside functionResponse.parts instead of sibling content parts.
if inline_data_list:
return [_part] + [{"inline_data": d} for d in inline_data_list]
_function_response["parts"] = [
{"inline_data": inline_data} for inline_data in inline_data_list
]
return [_part]
return _part
@ -3653,17 +3653,13 @@ from litellm.types.llms.bedrock import ContentBlock as BedrockContentBlock
from litellm.types.llms.bedrock import DocumentBlock as BedrockDocumentBlock
from litellm.types.llms.bedrock import ImageBlock as BedrockImageBlock
from litellm.types.llms.bedrock import SourceBlock as BedrockSourceBlock
from litellm.types.llms.bedrock import BedrockToolSpec
from litellm.types.llms.bedrock import ToolBlock as BedrockToolBlock
from litellm.types.llms.bedrock import (
ToolInputSchemaBlock as BedrockToolInputSchemaBlock,
)
from litellm.types.llms.bedrock import ToolJsonSchemaBlock as BedrockToolJsonSchemaBlock
from litellm.types.llms.bedrock import SearchResultBlock
from litellm.types.llms.bedrock import ToolResultBlock as BedrockToolResultBlock
from litellm.types.llms.bedrock import (
ToolResultContentBlock as BedrockToolResultContentBlock,
)
from litellm.types.llms.bedrock import ToolSpecBlock as BedrockToolSpecBlock
from litellm.types.llms.bedrock import ToolUseBlock as BedrockToolUseBlock
from litellm.types.llms.bedrock import VideoBlock as BedrockVideoBlock
@ -5496,6 +5492,7 @@ def _bedrock_tools_pt(
]
"""
from litellm.llms.bedrock.common_utils import (
get_bedrock_base_model,
normalize_json_schema_custom_types_to_object,
)
from litellm.litellm_core_utils.prompt_templates.common_utils import unpack_defs
@ -5503,6 +5500,11 @@ def _bedrock_tools_pt(
_valid_json_schema_root_types = frozenset(
("array", "boolean", "integer", "null", "number", "object", "string")
)
# Only Claude on Bedrock honours strict tool schemas; other families
# (Nova, Llama, GPT-OSS) reject the strict field outright.
supports_strict_tools = bool(
model and get_bedrock_base_model(model).startswith("anthropic")
)
tool_block_list: List[BedrockToolBlock] = []
for tool_idx, tool in enumerate(tools):
# Check if tool is already a BedrockToolBlock (e.g., systemTool for Nova grounding)
@ -5548,17 +5550,16 @@ def _bedrock_tools_pt(
normalize_json_schema_custom_types_to_object(parameters)
if parameters.get("type") not in _valid_json_schema_root_types:
parameters["type"] = "object"
tool_input_schema = BedrockToolInputSchemaBlock(
json=BedrockToolJsonSchemaBlock(
type=parameters["type"],
properties=parameters.get("properties", {}),
required=parameters.get("required", []),
)
tool_block = cast(
BedrockToolBlock,
BedrockToolSpec(
name=name,
description=description,
parameters=parameters,
strict=tool.get("function", {}).get("strict", None),
supports_strict_tools=supports_strict_tools,
),
)
tool_spec = BedrockToolSpecBlock(
inputSchema=tool_input_schema, name=name, description=description
)
tool_block = BedrockToolBlock(toolSpec=tool_spec)
tool_block_list.append(tool_block)
## ADD CACHE POINT TOOL BLOCK ##

View file

@ -92,8 +92,27 @@ class RealTimeStreaming:
# Track whether we have already sent the guardrail turn-detection update
# that disables provider auto-response for transcription guardrails.
self._guardrail_turn_detection_update_sent: bool = False
# Deferred Gemini Live setup: Pipecat may stream audio before session.update.
# Buffer client audio until the backend acknowledges setup (setupComplete).
self._backend_setup_complete: bool = (
provider_config is None or provider_config.requires_session_configuration()
)
self._flushing_pending_messages_until_setup: bool = False
self._pending_messages_until_setup: List[str] = []
self._pending_messages_byte_total: int = 0
# Per-connection caps for pre-setup audio frames (message count + total bytes).
_MAX_BUFFERED_MESSAGES: int = 200
_MAX_BUFFERED_BYTES: int = 10 * 1024 * 1024 # 10 MB
_SESSION_EVENT_TYPES = frozenset(["session.created", "session.updated"])
_CLIENT_AUDIO_BUFFER_TYPES = frozenset(
[
"input_audio_buffer.append",
"input_audio_buffer.commit",
"input_audio_buffer.clear",
]
)
_AUDIO_FORMAT_MAP: Dict[str, Dict[str, Any]] = {
"pcm16": {"type": "audio/pcm", "rate": 24000},
"g711_ulaw": {"type": "audio/G711-ulaw", "rate": 8000},
@ -285,6 +304,86 @@ class RealTimeStreaming:
await self.backend_ws.send(message) # type: ignore[union-attr, attr-defined]
return True
def _uses_deferred_backend_setup(self) -> bool:
"""True when setup is deferred until the client's first session.update."""
if self.provider_config is None:
return False
return not self.provider_config.requires_session_configuration()
def _should_buffer_client_message_until_setup(self, message: str) -> bool:
if not self._uses_deferred_backend_setup():
return False
if (
self._backend_setup_complete
and not self._flushing_pending_messages_until_setup
):
return False
try:
msg_obj = json.loads(message)
except (json.JSONDecodeError, TypeError):
return False
return msg_obj.get("type") in RealTimeStreaming._CLIENT_AUDIO_BUFFER_TYPES
def _buffer_pending_message_until_setup(self, message: str) -> None:
msg_bytes = len(message.encode("utf-8"))
if (
len(self._pending_messages_until_setup)
< RealTimeStreaming._MAX_BUFFERED_MESSAGES
and self._pending_messages_byte_total + msg_bytes
<= RealTimeStreaming._MAX_BUFFERED_BYTES
):
self._pending_messages_until_setup.append(message)
self._pending_messages_byte_total += msg_bytes
else:
verbose_logger.warning(
"Pre-setup buffer full (%d messages / %d bytes); dropping frame",
len(self._pending_messages_until_setup),
self._pending_messages_byte_total,
)
async def _flush_pending_messages_until_setup(self) -> bool:
pending = self._pending_messages_until_setup
self._pending_messages_until_setup = []
self._pending_messages_byte_total = 0
for idx, message in enumerate(pending):
try:
await self._send_to_backend(message)
except Exception as e:
unsent = pending[idx:]
self._pending_messages_until_setup = (
unsent + self._pending_messages_until_setup
)
self._pending_messages_byte_total = sum(
len(msg.encode("utf-8"))
for msg in self._pending_messages_until_setup
)
verbose_logger.debug(
"Failed to flush buffered client message after setup: %s "
"(%d buffered message(s) retained)",
e,
len(unsent),
)
return False
return True
async def _send_event_to_client(self, event: Any, event_str: str) -> bool:
if self._client_wants_beta and isinstance(event, dict):
try:
translated = self._translate_event_to_beta(event)
if translated is None:
return False
await self.websocket.send_text(json.dumps(translated))
return True
except Exception as e:
verbose_logger.warning(
"Failed to translate %s to beta protocol, forwarding "
"untranslated event to client: %s",
event.get("type"),
e,
)
await self.websocket.send_text(event_str)
return True
def _cache_session_configuration_request(self, transformed_message: str) -> None:
"""Store setup payload once sent to backend.
@ -547,6 +646,19 @@ class RealTimeStreaming:
isinstance(event, dict) and event.get("type") == "session.created"
)
if is_session_created_event:
if (
self._uses_deferred_backend_setup()
and not self._backend_setup_complete
):
self._backend_setup_complete = True
self._flushing_pending_messages_until_setup = True
try:
while self._pending_messages_until_setup:
flushed = await self._flush_pending_messages_until_setup()
if not flushed:
break
finally:
self._flushing_pending_messages_until_setup = False
if self._session_created_sent_to_client:
# A synthetic session.created (with placeholder defaults) was
# already forwarded to the client when we connected. The
@ -569,7 +681,7 @@ class RealTimeStreaming:
## update if a prior attempt was dropped by the provider transform.
if is_session_created_event and self._has_audio_transcription_guardrails():
self.store_message(event_str)
await self.websocket.send_text(event_str)
await self._send_event_to_client(event, event_str)
await self._maybe_send_guardrail_turn_detection_update()
continue
## GUARDRAIL: run on transcription events in provider_config path too
@ -581,7 +693,7 @@ class RealTimeStreaming:
transcript = event.get("transcript", "")
self._collect_user_input_from_backend_event(cast(dict, event))
self.store_message(event_str)
await self.websocket.send_text(event_str)
await self._send_event_to_client(event, event_str)
blocked = await self.run_realtime_guardrails(
cast(str, transcript),
item_id=cast(Optional[str], event.get("item_id")),
@ -591,7 +703,7 @@ class RealTimeStreaming:
continue
## LOGGING
self.store_message(event_str)
await self.websocket.send_text(event_str)
await self._send_event_to_client(event, event_str)
async def _handle_raw_backend_message(self, raw_response) -> bool:
"""Process a backend message without provider_config (raw path).
@ -880,6 +992,7 @@ class RealTimeStreaming:
## GUARDRAIL: intercept conversation.item.create for text-based injection.
guardrail_turn_detection_injected = False
msg_type: Optional[str] = None
try:
msg_obj = json.loads(message)
msg_type = msg_obj.get("type")
@ -1081,6 +1194,29 @@ class RealTimeStreaming:
# actually forward to the backend.
self.store_input(message=message)
if self._should_buffer_client_message_until_setup(message):
self._buffer_pending_message_until_setup(message)
continue
if self._pending_messages_until_setup:
should_send_setup_before_buffered_messages = (
not self._backend_setup_complete
and not self._flushing_pending_messages_until_setup
and msg_type == "session.update"
)
if not should_send_setup_before_buffered_messages:
self._buffer_pending_message_until_setup(message)
if (
self._backend_setup_complete
and not self._flushing_pending_messages_until_setup
):
await self._flush_pending_messages_until_setup()
continue
if self._flushing_pending_messages_until_setup:
self._buffer_pending_message_until_setup(message)
continue
## FORWARD TO BACKEND
# Only mark the guardrail turn_detection update as sent after the
# backend actually accepted the message. Setting the flag earlier

View file

@ -1149,6 +1149,32 @@ class CustomStreamWrapper:
completion_obj: Dict[str, Any] = {"content": ""}
from litellm.types.utils import GenericStreamingChunk as GChunk
if (
isinstance(chunk, ModelResponseStream)
and self.custom_llm_provider is not None
and self.custom_llm_provider in litellm._custom_providers
):
_has_content = bool(
chunk.choices
and chunk.choices[0].delta is not None
and (
chunk.choices[0].delta.content
or chunk.choices[0].delta.tool_calls
)
)
if self.received_finish_reason is not None:
if not _has_content:
raise StopIteration
if chunk.choices and chunk.choices[0].finish_reason:
self.received_finish_reason = chunk.choices[0].finish_reason
if not _has_content:
return None
# Strip finish_reason from the content chunk so it appears
# only on the trailing empty-delta chunk (OpenAI spec).
# finish_reason_handler() will emit the proper terminal chunk.
chunk.choices[0].finish_reason = None # type: ignore[assignment]
return chunk
if (
isinstance(chunk, dict)
and generic_chunk_has_all_required_fields(

View file

@ -81,7 +81,6 @@ from litellm.types.utils import (
from litellm.utils import (
ModelResponse,
Usage,
_supports_factory,
add_dummy_tool,
any_assistant_message_has_thinking_blocks,
get_max_tokens,
@ -337,50 +336,6 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
v in model_lower for v in ("opus-4-7", "opus_4_7", "opus-4.7", "opus_4.7")
)
@staticmethod
def _supports_model_capability(model: str, key: str) -> bool:
"""Check a boolean capability ``key`` in the model map.
Strips bedrock/vertex prefixes so a provider-routed Claude still
resolves to the Anthropic model-map entry.
"""
try:
if _supports_factory(
model=model,
custom_llm_provider="anthropic",
key=key,
):
return True
except Exception:
pass
candidates = [model]
for prefix in (
"bedrock/converse/",
"bedrock/invoke/",
"bedrock/",
"vertex_ai/",
):
if model.startswith(prefix):
candidates.append(model[len(prefix) :])
try:
from litellm.llms.bedrock.common_utils import BedrockModelInfo
base = BedrockModelInfo.get_base_model(model)
if base:
candidates.append(base)
candidates.append(f"bedrock/{base}")
except Exception:
pass
try:
for cand in candidates:
if cand in litellm.model_cost and (
litellm.model_cost[cand].get(key) is True
):
return True
except Exception:
pass
return False
@staticmethod
def _supports_effort_level(model: str, level: str) -> bool:
"""Check ``supports_{level}_reasoning_effort`` in the model map."""
@ -918,7 +873,39 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
anthropic_tools = []
mcp_servers = []
for tool in tools:
if "input_schema" in tool: # assume in anthropic format
if tool.get("type") == "namespace":
# Namespace is a grouping container (e.g. codex's multi_agent_v1).
# Extract its nested tools and map them individually.
for nested in tool.get("tools") or []:
if "input_schema" in nested:
# Already in Anthropic format.
anthropic_tools.append(nested)
elif "function" not in nested and "name" in nested:
# Flat format: {type, name, description, parameters, ...}.
# Normalize to OpenAI-wrapped format before mapping.
wrapped = cast(
ChatCompletionToolParam,
{
"type": nested.get("type", "function"),
"function": {
k: v for k, v in nested.items() if k != "type"
},
},
)
nested_tool, nested_mcp = self._map_tool_helper(wrapped)
if nested_tool is not None:
anthropic_tools.append(nested_tool)
if nested_mcp is not None:
mcp_servers.append(nested_mcp)
elif "function" in nested:
nested_tool, nested_mcp = self._map_tool_helper(
cast(ChatCompletionToolParam, nested)
)
if nested_tool is not None:
anthropic_tools.append(nested_tool)
if nested_mcp is not None:
mcp_servers.append(nested_mcp)
elif "input_schema" in tool: # assume in anthropic format
anthropic_tools.append(tool)
else: # assume openai tool call
new_tool, mcp_server_tool = self._map_tool_helper(tool)
@ -1978,6 +1965,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
# Remove internal LiteLLM parameters that should not be sent to Anthropic API
optional_params.pop("is_vertex_request", None)
optional_params.pop("client_metadata", None)
data = {
"model": model,

View file

@ -272,19 +272,63 @@ class AnthropicModelInfo(BaseLLMModelInfo):
)
@staticmethod
def _is_adaptive_thinking_model(model: str) -> bool:
"""Claude 4.6+ models use adaptive thinking with ``output_config.effort``."""
def _supports_model_capability(model: str, key: str) -> bool:
"""Check a boolean capability ``key`` in the model map.
Strips bedrock/vertex prefixes so a provider-routed Claude still
resolves to the Anthropic model-map entry.
"""
from litellm.utils import _supports_factory
try:
if _supports_factory(
model=model,
custom_llm_provider=None,
key="supports_adaptive_thinking",
custom_llm_provider="anthropic",
key=key,
):
return True
except Exception:
pass
candidates = [model]
for prefix in (
"bedrock/converse/",
"bedrock/invoke/",
"bedrock/",
"vertex_ai/",
):
if model.startswith(prefix):
candidates.append(model[len(prefix) :])
try:
from litellm.llms.bedrock.common_utils import BedrockModelInfo
base = BedrockModelInfo.get_base_model(model)
if base:
candidates.append(base)
candidates.append(f"bedrock/{base}")
except Exception:
pass
try:
for cand in candidates:
if cand in litellm.model_cost and (
litellm.model_cost[cand].get(key) is True
):
return True
except Exception:
pass
return False
@staticmethod
def _is_adaptive_thinking_model(model: str) -> bool:
"""Claude 4.6+ models use adaptive thinking with ``output_config.effort``.
Driven by the ``supports_adaptive_thinking`` flag in the model map; the
4.6/4.7 name checks remain only as a fallback for provider-routed ids
whose map entries predate the flag.
"""
if AnthropicModelInfo._supports_model_capability(
model, "supports_adaptive_thinking"
):
return True
return AnthropicModelInfo._is_claude_4_6_model(
model
) or AnthropicModelInfo._is_claude_4_7_model(model)

View file

@ -1510,6 +1510,17 @@ class LiteLLMAnthropicMessagesAdapter:
return "thinking", ChatCompletionThinkingBlock(
type="thinking", thinking=thinking, signature=signature
)
# OpenAI-compatible reasoning backends (e.g. vLLM/SGLang reasoning
# parsers) populate ``reasoning_content`` without ``thinking_blocks``.
# ``Delta`` deletes the ``thinking_blocks`` attribute when unset, so the
# branch above is skipped entirely; open a ``thinking`` block here so the
# matching ``thinking_delta`` stream is not emitted into a text block.
elif isinstance(choice, StreamingChoices) and getattr(
choice.delta, "reasoning_content", None
):
return "thinking", ChatCompletionThinkingBlock(
type="thinking", thinking="", signature=""
)
return "text", TextBlock(type="text", text="")

View file

@ -97,8 +97,15 @@ class AzureImageEditConfig(OpenAIImageEditConfig):
)
original_url = httpx.URL(api_base)
# Extract api_version or use default
api_version = cast(Optional[str], litellm_params.get("api_version"))
# Resolve api_version: litellm_params > litellm.api_version > AZURE_API_VERSION env > default.
# Mirrors the fallback chain used by the Azure chat path in common_utils.py,
# so callers that set a global / env api_version don't get an unversioned URL.
api_version = (
cast(Optional[str], litellm_params.get("api_version"))
or litellm.api_version
or get_secret_str("AZURE_API_VERSION")
or litellm.AZURE_DEFAULT_API_VERSION
)
# Create a new dictionary with existing params
query_params = dict(original_url.params)

View file

@ -0,0 +1,81 @@
"""
Amazon Bedrock Mantle - Responses API backend.
gpt-5.5 / gpt-5.4 on Mantle are exposed ONLY on the `/openai/v1/responses`
path (not the standard `/v1/responses`). Payloads and SSE follow the OpenAI
Responses spec, so this config inherits OpenAIResponsesAPIConfig and overrides
only the endpoint URL and Bearer authentication.
Auth: AWS Bedrock API key as Bearer token (BEDROCK_MANTLE_API_KEY or the
standard AWS_BEARER_TOKEN_BEDROCK), NOT SigV4.
"""
from typing import Optional
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
BEDROCK_MANTLE_DEFAULT_REGION = "us-east-1"
# Checked longest/most-specific first so a full endpoint URL collapses to host
# in one pass and the appended path never doubles.
_BASE_SUFFIXES_TO_STRIP = (
"/openai/v1/responses",
"/v1/responses",
"/responses",
"/openai/v1",
"/v1",
)
class BedrockMantleResponsesAPIConfig(OpenAIResponsesAPIConfig):
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.BEDROCK_MANTLE
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
region = (
get_secret_str("BEDROCK_MANTLE_REGION")
or get_secret_str("AWS_REGION")
or BEDROCK_MANTLE_DEFAULT_REGION
)
base = (
api_base
or get_secret_str("BEDROCK_MANTLE_API_BASE")
or f"https://bedrock-mantle.{region}.api.aws"
)
base = base.rstrip("/")
for suffix in _BASE_SUFFIXES_TO_STRIP:
if base.endswith(suffix):
base = base[: -len(suffix)]
break
return f"{base}/openai/v1/responses"
def validate_environment(
self, headers: dict, model: str, litellm_params: Optional[GenericLiteLLMParams]
) -> dict:
litellm_params = litellm_params or GenericLiteLLMParams()
api_key = (
litellm_params.api_key
or get_secret_str("BEDROCK_MANTLE_API_KEY")
or get_secret_str("AWS_BEARER_TOKEN_BEDROCK")
)
if not api_key:
raise ValueError(
"Bedrock Mantle API key is required. Set BEDROCK_MANTLE_API_KEY "
"(or AWS_BEARER_TOKEN_BEDROCK) or pass api_key."
)
headers["Authorization"] = f"Bearer {api_key}"
return headers
def supports_native_file_search(self) -> bool:
return False
def supports_native_websocket(self) -> bool:
return False

View file

@ -120,6 +120,7 @@ class CohereV2ChatConfig(OpenAIGPTConfig):
"stream",
"temperature",
"max_tokens",
"max_completion_tokens",
"top_p",
"frequency_penalty",
"presence_penalty",
@ -143,7 +144,12 @@ class CohereV2ChatConfig(OpenAIGPTConfig):
optional_params["stream"] = value
if param == "temperature":
optional_params["temperature"] = value
if param == "max_tokens":
if (
param == "max_tokens"
and "max_completion_tokens" not in non_default_params
):
optional_params["max_tokens"] = value
if param == "max_completion_tokens":
optional_params["max_tokens"] = value
if param == "n":
optional_params["num_generations"] = value

View file

@ -589,6 +589,7 @@ class AsyncHTTPHandler:
params: Optional[dict] = None,
headers: Optional[dict] = None,
follow_redirects: Optional[bool] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
):
# Set follow_redirects to UseClientDefault if None
_follow_redirects = (
@ -599,7 +600,11 @@ class AsyncHTTPHandler:
params.update(HTTPHandler.extract_query_params(url))
response = await self.client.get(
url, params=params, headers=headers, follow_redirects=_follow_redirects # type: ignore
url,
params=params,
headers=headers, # type: ignore
follow_redirects=_follow_redirects, # type: ignore
timeout=timeout if timeout is not None else USE_CLIENT_DEFAULT,
)
return response
@ -1115,6 +1120,7 @@ class HTTPHandler:
params: Optional[dict] = None,
headers: Optional[dict] = None,
follow_redirects: Optional[bool] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
):
# Set follow_redirects to UseClientDefault if None
_follow_redirects = (
@ -1128,6 +1134,7 @@ class HTTPHandler:
params=params,
headers=headers,
follow_redirects=_follow_redirects,
timeout=timeout if timeout is not None else USE_CLIENT_DEFAULT,
)
return response

View file

@ -1751,6 +1751,7 @@ class BaseLLMHTTPHandler:
api_base=api_base,
optional_params=optional_params,
data=data,
api_key=api_key,
)
## LOGGING
@ -1833,6 +1834,7 @@ class BaseLLMHTTPHandler:
api_base=api_base,
optional_params=optional_params,
data=data,
api_key=api_key,
)
## LOGGING
@ -2586,6 +2588,8 @@ class BaseLLMHTTPHandler:
headers=headers,
)
headers.setdefault("Content-Type", "application/json")
## LOGGING
logging_obj.pre_call(
input=input,
@ -2676,6 +2680,8 @@ class BaseLLMHTTPHandler:
headers=headers,
)
headers.setdefault("Content-Type", "application/json")
## LOGGING
logging_obj.pre_call(
input=input,
@ -5528,6 +5534,7 @@ class BaseLLMHTTPHandler:
user_api_key_dict: Optional[Any] = None,
litellm_metadata: Optional[Dict[str, Any]] = None,
custom_llm_provider: Optional[str] = None,
first_message: Optional[str] = None,
**kwargs: Any,
):
"""
@ -5559,6 +5566,7 @@ class BaseLLMHTTPHandler:
api_base=api_base,
timeout=timeout,
custom_llm_provider=custom_llm_provider,
first_message=first_message,
**kwargs,
)
await handler.run()
@ -5624,6 +5632,7 @@ class BaseLLMHTTPHandler:
logging_obj=logging_obj,
user_api_key_dict=user_api_key_dict,
request_data=_request_data,
first_message=first_message,
)
await streaming.bidirectional_forward()

View file

@ -7,6 +7,7 @@ from .flux_pro_v11_transformation import FalAIFluxProV11Config
from .flux_pro_v11_ultra_transformation import FalAIFluxProV11UltraConfig
from .flux_schnell_transformation import FalAIFluxSchnellConfig
from .imagen4_transformation import FalAIImagen4Config
from .nano_banana_transformation import FalAINanoBananaConfig
from .recraft_v3_transformation import FalAIRecraftV3Config
from .ideogram_v3_transformation import FalAIIdeogramV3Config
from .stable_diffusion_transformation import FalAIStableDiffusionConfig
@ -20,6 +21,7 @@ __all__ = [
"FalAIBaseConfig",
"FalAIImageGenerationConfig",
"FalAIImagen4Config",
"FalAINanoBananaConfig",
"FalAIRecraftV3Config",
"FalAIBriaConfig",
"FalAIFluxProV11Config",
@ -45,7 +47,9 @@ def get_fal_ai_image_generation_config(model: str) -> BaseImageGenerationConfig:
model_lower = model.lower()
# Map model names to their corresponding configuration classes
if "imagen4" in model_lower or "imagen-4" in model_lower:
if "nano-banana" in model_lower or "gemini-25-flash-image" in model_lower:
return FalAINanoBananaConfig()
elif "imagen4" in model_lower or "imagen-4" in model_lower:
return FalAIImagen4Config()
elif "recraft" in model_lower:
return FalAIRecraftV3Config()

View file

@ -0,0 +1,105 @@
from typing import List, Optional
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams
from .transformation import FalAIBaseConfig
class FalAINanoBananaConfig(FalAIBaseConfig):
"""
Configuration for Fal AI's Nano Banana / Gemini 2.5 Flash Image models.
Serves the imagen4 deprecation migration path. The same underlying model is
exposed under two endpoints that share an identical schema:
- fal-ai/nano-banana
- fal-ai/gemini-25-flash-image
Documentation: https://fal.ai/models/fal-ai/nano-banana
"""
SUPPORTED_ASPECT_RATIOS: List[str] = [
"21:9",
"16:9",
"3:2",
"4:3",
"5:4",
"1:1",
"4:5",
"3:4",
"2:3",
"9:16",
]
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
base_url: str = (
api_base or get_secret_str("FAL_AI_API_BASE") or self.DEFAULT_BASE_URL
).rstrip("/")
endpoint = model if model.startswith("fal-ai/") else f"fal-ai/{model}"
return f"{base_url}/{endpoint}"
def get_supported_openai_params(
self, model: str
) -> List[OpenAIImageGenerationOptionalParams]:
return ["n", "response_format", "size"]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
supported_params = self.get_supported_openai_params(model)
for key, value in non_default_params.items():
if key == "response_format":
continue
elif key == "n":
if "num_images" not in optional_params:
optional_params["num_images"] = value
elif key == "size":
if "aspect_ratio" not in optional_params:
optional_params["aspect_ratio"] = self._map_aspect_ratio(value)
elif key not in optional_params and not drop_params:
raise ValueError(
f"Parameter {key} is not supported for model {model}. "
f"Supported parameters are {supported_params}. "
"Set drop_params=True to drop unsupported parameters."
)
return optional_params
def _map_aspect_ratio(self, size: str) -> str:
if not isinstance(size, str) or "x" not in size:
return "1:1"
try:
width, height = (int(part) for part in size.split("x"))
target = width / height
except (ValueError, ZeroDivisionError):
return "1:1"
def ratio_of(aspect_ratio: str) -> float:
w, h = (int(part) for part in aspect_ratio.split(":"))
return w / h
return min(
self.SUPPORTED_ASPECT_RATIOS,
key=lambda aspect_ratio: abs(ratio_of(aspect_ratio) - target),
)
def transform_image_generation_request(
self,
model: str,
prompt: str,
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
return {"prompt": prompt, **optional_params}

View file

@ -170,11 +170,6 @@ class FireworksAIConfig(OpenAIGPTConfig):
is_response_format_supported=False,
enforce_tool_choice=False, # tools and response_format are both set, don't enforce tool_choice
)
elif "json_schema" in value:
optional_params["response_format"] = {
"type": "json_object",
"schema": value["json_schema"]["schema"],
}
else:
optional_params["response_format"] = value
elif param == "max_completion_tokens":

View file

@ -93,6 +93,7 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
"modalities",
"parallel_tool_calls",
"web_search_options",
"include_server_side_tool_invocations",
"service_tier",
]
if supports_reasoning(model, custom_llm_provider="gemini"):

View file

@ -1,6 +1,8 @@
import base64
import datetime
from typing import Any, Dict, List, Optional, Union
import json
import math
from typing import Any, Dict, List, Optional, Sequence, Union
import httpx
@ -12,6 +14,245 @@ from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import TokenCountResponse
GEMINI_IMAGE_ASPECT_RATIOS: Dict[str, float] = {
"1:1": 1 / 1,
"1:4": 1 / 4,
"1:8": 1 / 8,
"2:3": 2 / 3,
"3:2": 3 / 2,
"3:4": 3 / 4,
"4:1": 4 / 1,
"4:3": 4 / 3,
"4:5": 4 / 5,
"5:4": 5 / 4,
"8:1": 8 / 1,
"9:16": 9 / 16,
"16:9": 16 / 9,
"21:9": 21 / 9,
}
# Supported aspect ratio dimensions from Google Gemini image generation docs:
# https://ai.google.dev/gemini-api/docs/image-generation#aspect_ratios_and_image_size
GEMINI_IMAGE_SIZE_TO_ASPECT_RATIO: Dict[tuple[int, int], str] = {
(512, 512): "1:1",
(1024, 1024): "1:1",
(2048, 2048): "1:1",
(4096, 4096): "1:1",
(256, 1024): "1:4",
(512, 2048): "1:4",
(1024, 4096): "1:4",
(2048, 8192): "1:4",
(192, 1536): "1:8",
(384, 3072): "1:8",
(768, 6144): "1:8",
(1536, 12288): "1:8",
(424, 632): "2:3",
(848, 1264): "2:3",
(1696, 2528): "2:3",
(3392, 5056): "2:3",
(632, 424): "3:2",
(1264, 848): "3:2",
(2528, 1696): "3:2",
(5056, 3392): "3:2",
(448, 600): "3:4",
(896, 1200): "3:4",
(1792, 2400): "3:4",
(3584, 4800): "3:4",
(1024, 256): "4:1",
(2048, 512): "4:1",
(4096, 1024): "4:1",
(8192, 2048): "4:1",
(600, 448): "4:3",
(1200, 896): "4:3",
(2400, 1792): "4:3",
(4800, 3584): "4:3",
(464, 576): "4:5",
(928, 1152): "4:5",
(1856, 2304): "4:5",
(3712, 4608): "4:5",
(576, 464): "5:4",
(1152, 928): "5:4",
(2304, 1856): "5:4",
(4608, 3712): "5:4",
(1536, 192): "8:1",
(3072, 384): "8:1",
(6144, 768): "8:1",
(12288, 1536): "8:1",
(384, 688): "9:16",
(768, 1376): "9:16",
(1536, 2752): "9:16",
(3072, 5504): "9:16",
(688, 384): "16:9",
(1376, 768): "16:9",
(2752, 1536): "16:9",
(5504, 3072): "16:9",
(792, 336): "21:9",
(1584, 672): "21:9",
(3168, 1344): "21:9",
(6336, 2688): "21:9",
(1280, 896): "4:3",
(896, 1280): "3:4",
}
def map_openai_size_to_gemini_image_config(
size: str, model: str
) -> Optional[Dict[str, str]]:
dimensions = _parse_openai_image_size(size)
if dimensions is None:
return None
width, height = dimensions
image_config = {
"aspectRatio": _map_dimensions_to_gemini_aspect_ratio(width, height)
}
image_size = _map_dimensions_to_gemini_image_size(width, height)
if is_gemini_image_model(model):
if supports_gemini_image_size(model):
image_config["imageSize"] = image_size
else:
image_config["imageSize"] = image_size
return image_config
def supports_gemini_image_size(model: str) -> bool:
try:
model_info = litellm.get_model_info(model=model)
value = model_info.get("supports_image_size")
if value is not None:
return bool(value)
except Exception:
pass
return "2.5-flash" not in model
def is_gemini_image_model(model: str) -> bool:
base_model = model.split("/", 1)[-1]
return "gemini" in base_model
def map_openai_image_params_to_gemini(
params: Dict[str, Any],
model: str,
supported_params: Sequence[str],
optional_params: Optional[Dict[str, Any]] = None,
parse_image_config_string: bool = False,
) -> Dict[str, Any]:
optional_params = optional_params or {}
filtered_params = {
key: value for key, value in params.items() if key in supported_params
}
mapped_params: Dict[str, Any] = {}
if "n" in filtered_params and "n" not in optional_params:
mapped_params["sampleCount"] = filtered_params["n"]
if "size" in filtered_params and "size" not in optional_params:
image_config = map_openai_size_to_gemini_image_config(
filtered_params["size"],
model,
)
if image_config is not None:
if is_gemini_image_model(model):
mapped_params["imageConfig"] = image_config
else:
mapped_params["aspectRatio"] = image_config["aspectRatio"]
if "imageSize" in image_config:
mapped_params["imageSize"] = image_config["imageSize"]
image_config_param = filtered_params.get("imageConfig")
if isinstance(image_config_param, str) and parse_image_config_string:
try:
image_config_param = json.loads(image_config_param)
except json.JSONDecodeError as exc:
raise litellm.UnsupportedParamsError(
model=model,
message="`imageConfig` must be valid JSON when provided as a string.",
) from exc
if isinstance(image_config_param, dict):
mapped_params["imageConfig"] = image_config_param
for key, value in filtered_params.items():
if key not in ("n", "size", "imageConfig") and key not in optional_params:
mapped_params[key] = value
return mapped_params
def get_gemini_image_generation_config(
model: str,
optional_params: Dict[str, Any],
) -> Dict[str, Any]:
generation_config: Dict[str, Any] = {"response_modalities": ["IMAGE", "TEXT"]}
image_config: Dict[str, Any] = {}
if isinstance(optional_params.get("imageConfig"), dict):
image_config.update(optional_params["imageConfig"])
if not supports_gemini_image_size(model):
image_config.pop("imageSize", None)
if image_config:
generation_config["imageConfig"] = image_config
candidate_count = next(
(
optional_params[key]
for key in ("candidateCount", "candidate_count", "sampleCount", "n")
if optional_params.get(key) is not None
),
None,
)
if candidate_count is not None:
generation_config["candidateCount"] = candidate_count
return generation_config
def _parse_openai_image_size(size: str) -> Optional[tuple[int, int]]:
if size == "auto":
return None
width_str, separator, height_str = size.lower().partition("x")
if not separator:
return None
try:
width = int(width_str)
height = int(height_str)
except ValueError:
return None
if width <= 0 or height <= 0:
return None
return width, height
def _map_dimensions_to_gemini_aspect_ratio(width: int, height: int) -> str:
if (width, height) in GEMINI_IMAGE_SIZE_TO_ASPECT_RATIO:
return GEMINI_IMAGE_SIZE_TO_ASPECT_RATIO[(width, height)]
requested_ratio = width / height
return min(
GEMINI_IMAGE_ASPECT_RATIOS,
key=lambda aspect_ratio: abs(
math.log(GEMINI_IMAGE_ASPECT_RATIOS[aspect_ratio] / requested_ratio)
),
)
def _map_dimensions_to_gemini_image_size(width: int, height: int) -> str:
effective_square_side = math.sqrt(width * height)
if effective_square_side < 768:
return "512"
if effective_square_side < 1536:
return "1K"
if effective_square_side < 3072:
return "2K"
return "4K"
class GeminiError(BaseLLMException):
pass

View file

@ -4,8 +4,9 @@ Gemini Image Edit Cost Calculator
from typing import Any
import litellm
from litellm.types.utils import ImageResponse
from litellm.llms.gemini.image_generation.cost_calculator import (
cost_calculator as image_generation_cost_calculator,
)
def cost_calculator(
@ -15,20 +16,10 @@ def cost_calculator(
"""
Gemini image edit cost calculator.
Mirrors image generation pricing: charge per returned image based on
model metadata (`output_cost_per_image`).
Gemini image edits and generations share image response billing behavior:
use provider token usage when present, otherwise fall back to per-image pricing.
"""
model_info = litellm.get_model_info(
return image_generation_cost_calculator(
model=model,
custom_llm_provider="gemini",
image_response=image_response,
)
output_cost_per_image: float = model_info.get("output_cost_per_image") or 0.0
if not isinstance(image_response, ImageResponse):
raise ValueError(
f"image_response must be of type ImageResponse got type={type(image_response)}"
)
num_images = len(image_response.data or [])
return output_cost_per_image * num_images

View file

@ -7,10 +7,22 @@ from httpx._types import RequestFiles
from litellm.images.utils import ImageEditRequestUtils
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from litellm.llms.gemini.common_utils import (
get_gemini_image_generation_config,
map_openai_image_params_to_gemini,
)
from litellm.llms.gemini.image_usage_transformation import (
transform_gemini_image_usage,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.images.main import ImageEditOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import FileTypes, ImageObject, ImageResponse, OpenAIImage
from litellm.types.utils import (
FileTypes,
ImageObject,
ImageResponse,
OpenAIImage,
)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@ -22,7 +34,7 @@ else:
class GeminiImageEditConfig(BaseImageEditConfig):
DEFAULT_BASE_URL: str = "https://generativelanguage.googleapis.com/v1beta"
SUPPORTED_PARAMS: List[str] = ["size"]
SUPPORTED_PARAMS: List[str] = ["n", "size", "imageConfig"]
def get_supported_openai_params(self, model: str) -> List[str]:
return list(self.SUPPORTED_PARAMS)
@ -33,21 +45,12 @@ class GeminiImageEditConfig(BaseImageEditConfig):
model: str,
drop_params: bool,
) -> Dict[str, Any]:
supported_params = self.get_supported_openai_params(model)
filtered_params = {
key: value
for key, value in image_edit_optional_params.items()
if key in supported_params
}
mapped_params: Dict[str, Any] = {}
if "size" in filtered_params:
mapped_params["aspectRatio"] = self._map_size_to_aspect_ratio(
filtered_params["size"] # type: ignore[arg-type]
)
return mapped_params
return map_openai_image_params_to_gemini(
params=image_edit_optional_params, # type: ignore[arg-type]
model=model,
supported_params=self.get_supported_openai_params(model),
parse_image_config_string=True,
)
def validate_environment(
self,
@ -107,18 +110,10 @@ class GeminiImageEditConfig(BaseImageEditConfig):
request_body: Dict[str, Any] = {"contents": contents}
generation_config: Dict[str, Any] = {}
if "aspectRatio" in image_edit_optional_request_params:
# Move aspectRatio into imageConfig inside generationConfig
if "imageConfig" not in generation_config:
generation_config["imageConfig"] = {}
generation_config["imageConfig"]["aspectRatio"] = (
image_edit_optional_request_params["aspectRatio"]
)
if generation_config:
request_body["generationConfig"] = generation_config
request_body["generationConfig"] = get_gemini_image_generation_config(
model=model,
optional_params=image_edit_optional_request_params,
)
empty_files = cast(RequestFiles, [])
return request_body, empty_files
@ -156,18 +151,12 @@ class GeminiImageEditConfig(BaseImageEditConfig):
)
model_response.data = cast(List[OpenAIImage], data_list)
if "usageMetadata" in response_json:
model_response.usage = transform_gemini_image_usage(
response_json["usageMetadata"]
)
return model_response
def _map_size_to_aspect_ratio(self, size: str) -> str:
aspect_ratio_map = {
"1024x1024": "1:1",
"1792x1024": "16:9",
"1024x1792": "9:16",
"1280x896": "4:3",
"896x1280": "3:4",
}
return aspect_ratio_map.get(size, "1:1")
def _prepare_inline_image_parts(
self, image: Union[FileTypes, List[FileTypes]]
) -> List[Dict[str, Any]]:

View file

@ -5,18 +5,21 @@ import httpx
from litellm.llms.base_llm.image_generation.transformation import (
BaseImageGenerationConfig,
)
from litellm.llms.gemini.common_utils import (
get_gemini_image_generation_config,
is_gemini_image_model,
map_openai_image_params_to_gemini,
)
from litellm.llms.gemini.image_usage_transformation import (
transform_gemini_image_usage,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.gemini import GeminiImageGenerationRequest
from litellm.types.llms.openai import (
AllMessageValues,
OpenAIImageGenerationOptionalParams,
)
from litellm.types.utils import (
ImageObject,
ImageResponse,
ImageUsage,
ImageUsageInputTokensDetails,
)
from litellm.types.utils import ImageObject, ImageResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@ -36,7 +39,10 @@ class GoogleImageGenConfig(BaseImageGenerationConfig):
Google AI Imagen API supported parameters
https://ai.google.dev/gemini-api/docs/imagen
"""
return ["n", "size"]
supported_params = ["n", "size"]
if is_gemini_image_model(model):
supported_params.append("imageConfig")
return supported_params # type: ignore[return-value]
def map_openai_params(
self,
@ -45,64 +51,11 @@ class GoogleImageGenConfig(BaseImageGenerationConfig):
model: str,
drop_params: bool,
) -> dict:
supported_params = self.get_supported_openai_params(model)
mapped_params = {}
for k, v in non_default_params.items():
if k not in optional_params.keys():
if k in supported_params:
# Map OpenAI parameters to Google format
if k == "n":
mapped_params["sampleCount"] = v
elif k == "size":
# Map OpenAI size format to Google aspectRatio
mapped_params["aspectRatio"] = self._map_size_to_aspect_ratio(v)
else:
mapped_params[k] = v
return mapped_params
def _map_size_to_aspect_ratio(self, size: str) -> str:
"""
https://ai.google.dev/gemini-api/docs/image-generation
"""
aspect_ratio_map = {
"1024x1024": "1:1",
"1792x1024": "16:9",
"1024x1792": "9:16",
"1280x896": "4:3",
"896x1280": "3:4",
}
return aspect_ratio_map.get(size, "1:1")
def _transform_image_usage(self, usage_metadata: dict) -> ImageUsage:
"""
Transform Gemini usageMetadata to ImageUsage format
"""
input_tokens_details = ImageUsageInputTokensDetails(
image_tokens=0,
text_tokens=0,
)
# Extract detailed token counts from promptTokensDetails
tokens_details = usage_metadata.get("promptTokensDetails", [])
for details in tokens_details:
if isinstance(details, dict):
modality = str(details.get("modality", "")).upper()
raw_token_count = details.get(
"tokenCount", details.get("token_count", 0)
)
token_count = raw_token_count if isinstance(raw_token_count, int) else 0
if modality == "TEXT":
input_tokens_details.text_tokens += token_count
elif modality == "IMAGE":
input_tokens_details.image_tokens += token_count
return ImageUsage(
input_tokens=usage_metadata.get("promptTokenCount", 0),
input_tokens_details=input_tokens_details,
output_tokens=usage_metadata.get("candidatesTokenCount", 0),
total_tokens=usage_metadata.get("totalTokenCount", 0),
return map_openai_image_params_to_gemini(
params=non_default_params,
model=model,
supported_params=self.get_supported_openai_params(model),
optional_params=optional_params,
)
def get_complete_url(
@ -127,7 +80,7 @@ class GoogleImageGenConfig(BaseImageGenerationConfig):
complete_url = complete_url.rstrip("/")
# Gemini Flash Image Preview models use generateContent endpoint
if "gemini" in model:
if is_gemini_image_model(model):
complete_url = f"{complete_url}/models/{model}:generateContent"
else:
# All other Imagen models use predict endpoint
@ -179,10 +132,13 @@ class GoogleImageGenConfig(BaseImageGenerationConfig):
}
"""
# For Gemini Flash Image Preview models, use standard Gemini format
if "gemini" in model:
if is_gemini_image_model(model):
request_body: dict = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"response_modalities": ["IMAGE", "TEXT"]},
"generationConfig": get_gemini_image_generation_config(
model=model,
optional_params=optional_params,
),
}
return request_body
else:
@ -200,6 +156,9 @@ class GoogleImageGenConfig(BaseImageGenerationConfig):
)
return request_body_obj.model_dump(exclude_none=True)
def _transform_image_usage(self, usage_metadata: dict):
return transform_gemini_image_usage(usage_metadata)
def transform_image_generation_response(
self,
model: str,
@ -229,7 +188,7 @@ class GoogleImageGenConfig(BaseImageGenerationConfig):
model_response.data = []
# Handle different response formats based on model
if "gemini" in model:
if is_gemini_image_model(model):
# Gemini Flash Image Preview models return in candidates format
candidates = response_data.get("candidates", [])
for candidate in candidates:
@ -255,7 +214,7 @@ class GoogleImageGenConfig(BaseImageGenerationConfig):
# Extract usage metadata for Gemini models
if "usageMetadata" in response_data:
model_response.usage = self._transform_image_usage(
model_response.usage = transform_gemini_image_usage(
response_data["usageMetadata"]
)
else:

View file

@ -0,0 +1,73 @@
from typing import Any
from litellm.types.utils import ImageUsage, ImageUsageInputTokensDetails
def _get_token_count(details: dict) -> int:
raw_token_count = details.get("tokenCount", details.get("token_count", 0))
return raw_token_count if isinstance(raw_token_count, int) else 0
def _get_modality_token_details(usage_metadata: dict, *details_keys: str) -> list:
for details_key in details_keys:
details = usage_metadata.get(details_key)
if isinstance(details, list):
return details
return []
def _sum_modality_token_details(
usage_metadata: dict, *details_keys: str
) -> ImageUsageInputTokensDetails:
tokens_details = ImageUsageInputTokensDetails(
image_tokens=0,
text_tokens=0,
)
for details in _get_modality_token_details(usage_metadata, *details_keys):
if isinstance(details, dict):
modality = str(details.get("modality", "")).upper()
token_count = _get_token_count(details)
if modality == "TEXT":
tokens_details.text_tokens += token_count
elif modality == "IMAGE":
tokens_details.image_tokens += token_count
return tokens_details
def transform_gemini_image_usage(usage_metadata: dict) -> ImageUsage:
"""
Transform Gemini usageMetadata to ImageUsage format.
"""
input_tokens_details = _sum_modality_token_details(
usage_metadata, "promptTokensDetails", "prompt_tokens_details"
)
output_tokens = usage_metadata.get("candidatesTokenCount", 0)
output_tokens_details = _sum_modality_token_details(
usage_metadata, "candidatesTokensDetails", "candidates_tokens_details"
)
if not _get_modality_token_details(
usage_metadata, "candidatesTokensDetails", "candidates_tokens_details"
):
output_tokens_details.image_tokens = output_tokens
else:
known_output_tokens = (
output_tokens_details.text_tokens + output_tokens_details.image_tokens
)
if output_tokens > known_output_tokens:
output_tokens_details.text_tokens += output_tokens - known_output_tokens
usage_payload: dict[str, Any] = {
"input_tokens": usage_metadata.get("promptTokenCount", 0),
"input_tokens_details": input_tokens_details,
"output_tokens": output_tokens,
"total_tokens": usage_metadata.get("totalTokenCount", 0),
"prompt_tokens": usage_metadata.get("promptTokenCount", 0),
"prompt_tokens_details": input_tokens_details.model_dump(),
"completion_tokens": output_tokens,
"completion_tokens_details": output_tokens_details.model_dump(),
"output_tokens_details": output_tokens_details.model_dump(),
}
return ImageUsage(**usage_payload)

View file

@ -27,7 +27,6 @@ from litellm.types.llms.gemini import (
)
from litellm.types.llms.openai import (
OpenAIRealtimeContentPartDone,
OpenAIRealtimeConversationItemCreated,
OpenAIRealtimeDoneEvent,
OpenAIRealtimeEvents,
OpenAIRealtimeEventTypes,
@ -79,6 +78,12 @@ _KNOWN_GEMINI_TOP_LEVEL_KEYS: set = {
map_key.split(".", 1)[0] for map_key in MAP_GEMINI_FIELD_TO_OPENAI_EVENT
}
# Gemini Live native-audio model ids carry this marker (e.g.
# ``gemini-2.5-flash-native-audio-preview-09-2025``). These models reject a
# ``speechConfig`` on ``setup`` with a 1007 invalid-argument error, so it is
# stripped in ``_finalize_gemini_live_setup``.
_GEMINI_NATIVE_AUDIO_MODEL_MARKER = "native-audio"
class GeminiRealtimeConfig(BaseRealtimeConfig):
# Cap the LRU of in-flight tool calls so long sessions with many tool
@ -98,6 +103,33 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
# bypassing spend and budget accounting.
self._pending_usage_metadata: Optional[dict] = None
@staticmethod
def _usage_detail_alias(details: Any, defaults: Dict[str, int]) -> Dict[str, Any]:
if not isinstance(details, dict):
return dict(defaults)
return {
**defaults,
**{key: value for key, value in details.items() if value is not None},
}
@staticmethod
def _add_pipecat_usage_detail_aliases(usage_dict: Dict[str, Any]) -> Dict[str, Any]:
usage_dict.setdefault(
"input_token_details",
GeminiRealtimeConfig._usage_detail_alias(
usage_dict.get("input_tokens_details"),
{"cached_tokens": 0, "text_tokens": 0, "audio_tokens": 0},
),
)
usage_dict.setdefault(
"output_token_details",
GeminiRealtimeConfig._usage_detail_alias(
usage_dict.get("output_tokens_details"),
{"text_tokens": 0, "audio_tokens": 0},
),
)
return usage_dict
def validate_environment(
self, headers: dict, model: str, api_key: Optional[str] = None
) -> dict:
@ -173,9 +205,25 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
def map_automatic_turn_detection(
self, value: OpenAIRealtimeTurnDetection
) -> AutomaticActivityDetection:
"""Map OpenAI ``server_vad`` to Gemini ``automaticActivityDetection``.
OpenAI ``semantic_vad`` has no Gemini Live equivalent return an empty
dict so callers omit ``realtimeInputConfig`` (mapping it with
``disabled: true`` breaks native-audio sessions).
"""
if (
isinstance(value, dict)
and value.get("type") == "semantic_vad"
and "create_response" not in value
):
return AutomaticActivityDetection()
automatic_activity_dection = AutomaticActivityDetection()
if "create_response" in value and isinstance(value["create_response"], bool):
automatic_activity_dection["disabled"] = not value["create_response"]
elif isinstance(value, dict) and value.get("type") == "server_vad":
# OpenAI server VAD enables activity detection by default.
automatic_activity_dection["disabled"] = False
else:
automatic_activity_dection["disabled"] = True
if "prefix_padding_ms" in value and isinstance(value["prefix_padding_ms"], int):
@ -197,6 +245,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
"tools",
"input_audio_transcription",
"turn_detection",
"voice",
]
def map_openai_params(
@ -231,17 +280,33 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
optional_params["inputAudioTranscription"] = {}
elif key == "turn_detection":
value_typed = cast(OpenAIRealtimeTurnDetection, value)
if (
isinstance(value_typed, dict)
and value_typed.get("type") == "semantic_vad"
and "create_response" not in value_typed
):
# Pipecat/OpenAI GA semantic VAD — skip; Gemini uses its own VAD.
# Only skip when there is no create_response override so that
# a guardrail-injected create_response:false is not dropped.
continue
transformed_audio_activity_config = self.map_automatic_turn_detection(
value_typed
)
if (
len(transformed_audio_activity_config) > 0
): # if the config is not empty, add it to the optional params
if transformed_audio_activity_config:
optional_params["realtimeInputConfig"] = (
BidiGenerateContentRealtimeInputConfig(
automaticActivityDetection=transformed_audio_activity_config
)
)
elif key == "voice":
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
vertex_gemini_config = VertexGeminiConfig()
speech_config = vertex_gemini_config._map_audio_params({"voice": value})
if speech_config:
optional_params["generationConfig"]["speechConfig"] = speech_config
if len(optional_params["generationConfig"]) == 0:
optional_params.pop("generationConfig")
return optional_params
@ -297,6 +362,9 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
and "transcription" in input_cfg
):
normalized["input_audio_transcription"] = input_cfg["transcription"]
output_cfg = audio.get("output")
if isinstance(output_cfg, dict) and output_cfg.get("voice"):
normalized["voice"] = output_cfg["voice"]
extracted_turn_detection = GeminiRealtimeConfig._extract_turn_detection(
normalized
@ -308,6 +376,18 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
return normalized
@staticmethod
def _finalize_gemini_live_setup(
model: str, setup: Dict[str, Any]
) -> Dict[str, Any]:
"""Drop fields Gemini Live native-audio rejects on ``setup``."""
if _GEMINI_NATIVE_AUDIO_MODEL_MARKER not in model.lower():
return setup
generation_config = setup.get("generationConfig")
if isinstance(generation_config, dict):
generation_config.pop("speechConfig", None)
return setup
def _handle_session_update(
self,
json_message: dict,
@ -351,7 +431,11 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
verbose_logger.debug(
"Gemini Realtime: Sending initial setup with tools to backend"
)
return [json.dumps({"setup": new_overrides})]
return [
json.dumps(
{"setup": self._finalize_gemini_live_setup(model, new_overrides)}
)
]
if not new_overrides:
verbose_logger.debug(
@ -420,7 +504,15 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
verbose_logger.debug(
"Gemini Realtime: Forwarding session.update as follow-up setup"
)
return [json.dumps({"setup": follow_up_setup})]
return [
json.dumps(
{
"setup": self._finalize_gemini_live_setup(
model, cast(Dict[str, Any], follow_up_setup)
)
}
)
]
def _handle_conversation_item(self, json_message: dict) -> List[str]:
"""
@ -666,6 +758,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
"object": "realtime.response",
"id": response_id,
"status": "in_progress",
"status_details": None,
"output": [],
"conversation_id": conversation_id,
"modalities": _modalities,
@ -675,9 +768,10 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
)
response_items.append(response_created)
## - return response.output_item.added ← adds item_id same for all subsequent events
## - return response.output_item.added
response_output_item_added = OpenAIRealtimeStreamResponseOutputItemAdded(
type="response.output_item.added",
event_id="event_{}".format(uuid.uuid4()),
response_id=response_id,
output_index=0,
item={
@ -690,20 +784,28 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
},
)
response_items.append(response_output_item_added)
## - return conversation.item.created
conversation_item_created = OpenAIRealtimeConversationItemCreated(
type="conversation.item.created",
event_id="event_{}".format(uuid.uuid4()),
item={
"id": output_item_id,
"object": "realtime.item",
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": [],
},
## - return conversation.item.added
# Pipecat 1.3.x handles "conversation.item.added" (not ".created").
# Sending ".created" raises "Unimplemented server event type" which
# kills the receive task handler.
response_items.append(
cast(
OpenAIRealtimeEvents,
{
"type": "conversation.item.added",
"event_id": "event_{}".format(uuid.uuid4()),
"previous_item_id": None,
"item": {
"id": output_item_id,
"object": "realtime.item",
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": [],
},
},
)
)
response_items.append(conversation_item_created)
## - return response.content_part.added
response_content_part_added = OpenAIRealtimeResponseContentPartAdded(
type="response.content_part.added",
@ -749,9 +851,9 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
return OpenAIRealtimeResponseDelta(
type=(
"response.text.delta"
"response.output_text.delta"
if delta_type == "text"
else "response.audio.delta"
else "response.output_audio.delta"
),
content_index=0,
event_id="event_{}".format(uuid.uuid4()),
@ -778,7 +880,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
current_response_id = "resp_{}".format(uuid.uuid4())
if delta_type == "text":
return OpenAIRealtimeResponseTextDone(
type="response.text.done",
type="response.output_text.done",
content_index=0,
event_id="event_{}".format(uuid.uuid4()),
item_id=current_output_item_id,
@ -788,7 +890,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
)
elif delta_type == "audio":
return OpenAIRealtimeResponseAudioDone(
type="response.audio.done",
type="response.output_audio.done",
content_index=0,
event_id="event_{}".format(uuid.uuid4()),
item_id=current_output_item_id,
@ -914,7 +1016,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
events: List[OpenAIRealtimeFunctionCallArgumentsDone] = []
for idx, fc in enumerate(function_calls):
call_id = fc.get("id", "")
call_id = fc.get("id", "") or f"call_{uuid.uuid4().hex[:16]}"
name = fc.get("name", "")
# Store call_id → name mapping for round-trip. Use an LRU so
@ -962,7 +1064,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
current_delta_chunks = []
any_delta_chunk = False
for event in transformed_message:
if event["type"] == "response.text.delta":
if event["type"] == "response.output_text.delta":
current_delta_chunks.append(
cast(OpenAIRealtimeResponseDelta, event)
)
@ -973,7 +1075,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
)
else:
if (
transformed_message["type"] == "response.text.delta"
transformed_message["type"] == "response.output_text.delta"
): # ONLY ACCUMULATE TEXT DELTA CHUNKS - AUDIO WILL CAUSE SERVER MEMORY ISSUES
if current_delta_chunks is None:
current_delta_chunks = []
@ -1067,6 +1169,8 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
responses_api_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(
_chat_completion_usage,
)
_usage_dict = responses_api_usage.model_dump()
self._add_pipecat_usage_detail_aliases(_usage_dict)
response_done_event = OpenAIRealtimeDoneEvent(
type="response.done",
event_id="event_{}".format(uuid.uuid4()),
@ -1074,6 +1178,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
object="realtime.response",
id=current_response_id,
status="completed",
status_details=None, # type: ignore[typeddict-item]
output=(
[output_item["item"] for output_item in output_items]
if output_items
@ -1081,7 +1186,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
),
conversation_id=current_conversation_id,
modalities=_modalities,
usage=responses_api_usage.model_dump(),
usage=_usage_dict,
),
)
if temperature is not None:
@ -1294,19 +1399,36 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
output_tx = server_content.get("outputTranscription")
if isinstance(output_tx, dict) and output_tx.get("text"):
if current_response_id is None:
current_response_id = "resp_{}".format(uuid.uuid4())
if current_output_item_id is None:
current_output_item_id = "item_{}".format(uuid.uuid4())
current_conversation_id = (
current_conversation_id or "conv_{}".format(uuid.uuid4())
)
returned_message.extend(
self.return_new_content_delta_events(
session_configuration_request=session_configuration_request,
response_id=current_response_id,
output_item_id=current_output_item_id,
conversation_id=current_conversation_id,
delta_type="audio",
)
)
# Emit as the GA event name; _GA_TO_BETA_EVENT_TYPES translates
# this back to response.audio_transcript.delta for beta clients.
returned_message.append(
cast(
OpenAIRealtimeEvents,
{
"type": "response.audio_transcript.delta",
"type": "response.output_audio_transcript.delta",
"event_id": "event_{}".format(uuid.uuid4()),
"delta": output_tx["text"],
"item_id": current_output_item_id
or "item_{}".format(uuid.uuid4()),
"response_id": current_response_id
or "resp_{}".format(uuid.uuid4()),
"output_index": 0,
"transcript": output_tx["text"],
"item_id": current_output_item_id,
"content_index": 0,
"output_index": 0,
"response_id": current_response_id,
"delta": output_tx["text"],
},
)
)
@ -1416,6 +1538,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
"object": "realtime.response",
"id": current_response_id,
"status": "in_progress",
"status_details": None,
"output": [],
"conversation_id": current_conversation_id,
"modalities": tool_call_modalities,
@ -1460,6 +1583,29 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
},
)
)
# conversation.item.added — Pipecat 1.3.x registers the
# call_id into _pending_function_calls inside
# _handle_evt_conversation_item_added, which is triggered
# by this event (NOT by response.output_item.added and NOT
# by the old conversation.item.created which Pipecat 1.3.x
# does not handle). Without this event the subsequent
# response.function_call_arguments.done finds an empty
# pending-calls dict and drops the tool invocation silently.
returned_message.append(
cast(
OpenAIRealtimeEvents,
{
"type": "conversation.item.added",
"event_id": f"event_{uuid.uuid4()}",
"previous_item_id": None,
"item": {
**function_call_item,
"status": "in_progress",
"arguments": "",
},
},
)
)
# response.function_call_arguments.delta — Gemini delivers
# the full arguments string in a single toolCall frame
# rather than streaming partial chunks, so emit one delta
@ -1496,14 +1642,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
item={**function_call_item},
)
)
# conversation.item.created
returned_message.append(
OpenAIRealtimeConversationItemCreated(
type="conversation.item.created",
event_id=f"event_{uuid.uuid4()}",
item={**function_call_item},
)
)
# response.done - close the response so clients can submit tool
# results. Mirror the non-tool-call RESPONSE_DONE path: if Gemini
@ -1537,6 +1675,8 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
tool_call_responses_api_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(
_tool_call_chat_completion_usage,
)
_tool_usage_dict = tool_call_responses_api_usage.model_dump()
self._add_pipecat_usage_detail_aliases(_tool_usage_dict)
tool_call_done_event = OpenAIRealtimeDoneEvent(
type="response.done",
event_id=f"event_{uuid.uuid4()}",
@ -1544,6 +1684,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
id=current_response_id,
object="realtime.response",
status="completed",
status_details=None, # type: ignore[typeddict-item]
output=[
{
"id": te["item_id"],
@ -1558,7 +1699,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
],
conversation_id=current_conversation_id,
modalities=tool_call_modalities,
usage=tool_call_responses_api_usage.model_dump(),
usage=_tool_usage_dict,
),
)
tool_call_temperature = tool_call_generation_config.get("temperature")

View file

@ -265,7 +265,11 @@ class GeminiVideoConfig(BaseVideoConfig):
{
"instances": [
{
"prompt": "A cat playing with a ball of yarn"
"prompt": "A cat playing with a ball of yarn",
"image": {
"bytesBase64Encoded": "...",
"mimeType": "image/jpeg"
}
}
],
"parameters": {
@ -275,13 +279,18 @@ class GeminiVideoConfig(BaseVideoConfig):
}
}
"""
instance = GeminiVideoGenerationInstance(prompt=prompt)
instance: GeminiVideoGenerationInstance = {"prompt": prompt}
params_copy = video_create_optional_request_params.copy()
if "image" in params_copy and params_copy["image"] is not None:
image_data = _convert_image_to_gemini_format(params_copy["image"])
params_copy["image"] = image_data
if "image" in params_copy:
image = params_copy.pop("image")
if image is not None:
if isinstance(image, dict):
image_data = image
else:
image_data = _convert_image_to_gemini_format(image)
instance["image"] = image_data
parameters = GeminiVideoGenerationParameters(**params_copy)

View file

@ -239,7 +239,7 @@ class HuggingFaceEmbedding(BaseLLM):
model_response.model = model
input_tokens = 0
for text in input:
input_tokens += len(encoding.encode(text))
input_tokens += len(encoding.encode(text, disallowed_special=()))
setattr(
model_response,

View file

View file

View file

@ -0,0 +1,54 @@
"""
Translate from OpenAI's `/v1/chat/completions` to Inception's `/v1/chat/completions`
Inception Labs (https://www.inceptionlabs.ai) serves the Mercury family of
diffusion LLMs through an OpenAI-compatible API, so we only need to point the
OpenAI-like handler at the Inception API base and pick up the Inception API key.
"""
from typing import List, Optional, Tuple
import litellm
from litellm.secret_managers.main import get_secret_str
from ...openai_like.chat.transformation import OpenAILikeChatConfig
class InceptionChatConfig(OpenAILikeChatConfig):
"""
Inception is OpenAI-compatible with standard endpoints
"""
@property
def custom_llm_provider(self) -> Optional[str]:
return "inception"
def get_supported_openai_params(self, model: str) -> List:
return [
"max_tokens",
"max_completion_tokens",
"temperature",
"stop",
"tools",
"tool_choice",
"stream",
"stream_options",
"response_format",
"reasoning_effort",
"reasoning_summary",
"reasoning_summary_wait",
"diffusing",
"realtime",
]
def _get_openai_compatible_provider_info(
self, api_base: Optional[str], api_key: Optional[str]
) -> Tuple[Optional[str], Optional[str]]:
passed_api_base = api_base
api_base = api_base or get_secret_str("INCEPTION_API_BASE") or "https://api.inceptionlabs.ai/v1" # type: ignore
dynamic_api_key = api_key
if passed_api_base is None or api_key:
dynamic_api_key = (
api_key or litellm.inception_key or get_secret_str("INCEPTION_API_KEY")
)
return api_base, dynamic_api_key

View file

@ -0,0 +1,43 @@
"""
Inception fill-in-the-middle (FIM) completions.
Inception's FIM endpoint is OpenAI text-completion compatible: it takes a
`prompt` (prefix) plus an optional `suffix` and returns standard
`choices[].text`. It is served at `/v1/fim/completions` rather than
`/v1/completions`, so routing points the OpenAI client at the `/v1/fim` base
(see the `text-completion-inception` branch in `main.py`).
"""
from typing import List
from litellm.llms.openai.completion.transformation import OpenAITextCompletionConfig
class InceptionTextCompletionConfig(OpenAITextCompletionConfig):
def get_supported_openai_params(self, model: str) -> List:
return [
"suffix",
"max_tokens",
"max_completion_tokens",
"top_p",
"frequency_penalty",
"presence_penalty",
"stop",
"stream",
"stream_options",
]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
supported_params = self.get_supported_openai_params(model)
for param, value in non_default_params.items():
if param == "max_completion_tokens":
optional_params["max_tokens"] = value
elif param in supported_params:
optional_params[param] = value
return optional_params

View file

@ -17,6 +17,7 @@ from litellm.proxy.common_utils.resource_ownership import (
is_proxy_admin,
user_can_access_resource_owner,
)
from litellm.repositories.table_repositories import SkillsRepository
# Skills are looked up on every chat completion that has skills enabled
# (`SkillsInjectionHook` calls ``fetch_skill_from_db``). 60s LRU/TTL cache
@ -107,7 +108,7 @@ class LiteLLMSkillsHandler:
f"LiteLLMSkillsHandler: Creating skill {skill_id} with title={data.display_title}"
)
new_skill = await prisma_client.db.litellm_skillstable.create(data=skill_data)
new_skill = await SkillsRepository(prisma_client).table.create(data=skill_data)
return _prisma_skill_to_litellm(new_skill)
@staticmethod
@ -133,7 +134,7 @@ class LiteLLMSkillsHandler:
return []
find_many_kwargs["where"] = {"created_by": {"in": owner_scopes}}
skills = await prisma_client.db.litellm_skillstable.find_many(
skills = await SkillsRepository(prisma_client).table.find_many(
**find_many_kwargs
)
return [_prisma_skill_to_litellm(s) for s in skills]
@ -150,7 +151,7 @@ class LiteLLMSkillsHandler:
return cached
prisma_client = await LiteLLMSkillsHandler._get_prisma_client()
skill = await prisma_client.db.litellm_skillstable.find_unique(
skill = await SkillsRepository(prisma_client).table.find_unique(
where={"skill_id": skill_id}
)
_SKILL_CACHE.set_cache(
@ -189,7 +190,7 @@ class LiteLLMSkillsHandler:
):
raise ValueError(f"Skill not found: {skill_id}")
await prisma_client.db.litellm_skillstable.delete(where={"skill_id": skill_id})
await SkillsRepository(prisma_client).table.delete(where={"skill_id": skill_id})
_SKILL_CACHE.set_cache(skill_id, _NEGATIVE_SKILL_SENTINEL)
return {"id": skill_id, "type": "skill_deleted"}

View file

@ -134,11 +134,15 @@ class MoonshotChatConfig(OpenAIGPTConfig):
##########################################
# temperature limitations
# 1. `temperature` on KIMI API is [0, 1] but OpenAI is [0, 2]
# 2. If temperature < 0.3 and n > 1, KIMI will raise an exception.
# 1. reasoning models (kimi-k2.5, kimi-k2.6, ...) reject every temperature
# except 1, so the param is dropped and the model's default is used
# 2. `temperature` on KIMI API is [0, 1] but OpenAI is [0, 2]
# 3. If temperature < 0.3 and n > 1, KIMI will raise an exception.
# If we enter this condition, we set the temperature to 0.3 as suggested by Moonshot AI
##########################################
if "temperature" in optional_params:
if supports_reasoning(model=model, custom_llm_provider="moonshot"):
optional_params.pop("temperature", None)
elif "temperature" in optional_params:
if optional_params["temperature"] > 1:
optional_params["temperature"] = 1
if optional_params["temperature"] < 0.3 and optional_params.get("n", 1) > 1:

View file

@ -115,6 +115,15 @@
"max_completion_tokens": "max_tokens"
}
},
"neosantara": {
"base_url": "https://api.neosantara.xyz/v1",
"api_key_env": "NEOSANTARA_API_KEY",
"api_base_env": "NEOSANTARA_API_BASE",
"param_mappings": {
"max_completion_tokens": "max_tokens"
},
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
},
"tensormesh": {
"base_url": "https://serverless.tensormesh.ai/v1",
"api_key_env": "TENSORMESH_INFERENCE_API_KEY",

View file

@ -25,6 +25,7 @@ class SnowflakeBaseConfig:
"temperature",
"max_tokens",
"top_p",
"stream",
"response_format",
"tools",
"tool_choice",

View file

@ -0,0 +1 @@
"""Soniox LLM provider implementation."""

View file

@ -0,0 +1 @@
"""Soniox audio transcription implementation."""

View file

@ -0,0 +1,802 @@
"""
Handler for Soniox async speech-to-text transcription.
Soniox's async transcription API requires multiple HTTP calls:
1. (optional) POST /v1/files upload a local audio file
2. POST /v1/transcriptions create a transcription job
3. GET /v1/transcriptions/{id} poll until status == "completed"
4. GET /v1/transcriptions/{id}/transcript fetch the transcript
5. (optional) DELETE /v1/transcriptions/{id} cleanup
6. (optional) DELETE /v1/files/{id} cleanup
Because this does not fit the single-request shape of
`base_llm_http_handler.audio_transcriptions`, the dispatch in
`litellm.main.transcription()` routes Soniox requests directly to this
handler (analogous to the OpenAI / Azure transcription handlers).
"""
import asyncio
import math
import time
from typing import (
TYPE_CHECKING,
Any,
Coroutine,
Dict,
List,
Optional,
Tuple,
Union,
)
import httpx
from litellm.litellm_core_utils.audio_utils.utils import (
get_audio_file_name,
process_audio_file,
)
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
HTTPHandler,
_get_httpx_client,
get_async_httpx_client,
)
from litellm.llms.soniox.audio_transcription.transformation import (
SonioxAudioTranscriptionConfig,
)
from litellm.llms.soniox.common_utils import (
SONIOX_DEFAULT_CLEANUP,
SONIOX_DEFAULT_MAX_POLL_ATTEMPTS,
SONIOX_DEFAULT_POLL_INTERVAL,
SONIOX_MAX_POLL_ATTEMPTS,
SONIOX_MAX_POLL_INTERVAL,
SONIOX_MIN_POLL_INTERVAL,
SONIOX_SECRET_FIELDS,
SonioxException,
get_soniox_api_base,
)
from litellm.types.utils import FileTypes, TranscriptionResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import (
Logging as LiteLLMLoggingObj,
)
else:
LiteLLMLoggingObj = Any
class SonioxAudioTranscriptionHandler:
"""Orchestrates the Soniox async transcription flow."""
# ------------------------------------------------------------------
# Public entry points
# ------------------------------------------------------------------
def audio_transcriptions(
self,
model: str,
audio_file: Optional[FileTypes],
optional_params: dict,
litellm_params: dict,
model_response: TranscriptionResponse,
timeout: float,
max_retries: int,
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str],
api_base: Optional[str],
client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None,
atranscription: bool = False,
headers: Optional[Dict[str, Any]] = None,
provider_config: Optional[SonioxAudioTranscriptionConfig] = None,
) -> Union[TranscriptionResponse, Coroutine[Any, Any, TranscriptionResponse]]:
"""Sync/async dispatch for Soniox transcription requests.
Note: ``max_retries`` is accepted for signature compatibility with
``litellm.transcription`` but is **not yet implemented** for the Soniox
async pipeline. Transient HTTP failures during upload, create, poll,
or fetch will surface immediately. Wrap calls with the standard
``litellm.Router`` / ``num_retries`` mechanism for retry behaviour.
"""
config = provider_config or SonioxAudioTranscriptionConfig()
if atranscription is True:
return self._async_audio_transcriptions(
model=model,
audio_file=audio_file,
optional_params=optional_params,
litellm_params=litellm_params,
model_response=model_response,
timeout=timeout,
logging_obj=logging_obj,
api_key=api_key,
api_base=api_base,
client=client if isinstance(client, AsyncHTTPHandler) else None,
headers=headers or {},
provider_config=config,
)
return self._sync_audio_transcriptions(
model=model,
audio_file=audio_file,
optional_params=optional_params,
litellm_params=litellm_params,
model_response=model_response,
timeout=timeout,
logging_obj=logging_obj,
api_key=api_key,
api_base=api_base,
client=client if isinstance(client, HTTPHandler) else None,
headers=headers or {},
provider_config=config,
)
# ------------------------------------------------------------------
# Helpers shared between sync and async paths
# ------------------------------------------------------------------
def _prepare(
self,
audio_file: Optional[FileTypes],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str],
api_base: Optional[str],
provider_config: SonioxAudioTranscriptionConfig,
headers: Dict[str, Any],
) -> Tuple[
Dict[str, str], # auth headers
str, # api_base (no trailing slash)
Dict[str, Any], # body for POST /v1/transcriptions (without file_id/audio_url)
Dict[str, Any], # handler-only options (poll interval, cleanup, ...)
]:
# Validate env -> auth headers.
auth_headers = provider_config.validate_environment(
headers=headers,
model="", # unused
messages=[],
optional_params=optional_params,
litellm_params=litellm_params,
api_key=api_key,
api_base=api_base,
)
base_url = get_soniox_api_base(api_base)
# Operate on a local copy so we don't mutate the caller's dict
# (the caller may reuse `optional_params` for retries or logging).
params = dict(optional_params)
# Pull handler-only kwargs out of params so they aren't sent
# to Soniox.
poll_interval = float(
params.pop("soniox_polling_interval", SONIOX_DEFAULT_POLL_INTERVAL)
)
try:
max_attempts = int(
params.pop(
"soniox_max_polling_attempts", SONIOX_DEFAULT_MAX_POLL_ATTEMPTS
)
)
except (ValueError, OverflowError):
max_attempts = SONIOX_DEFAULT_MAX_POLL_ATTEMPTS
cleanup_raw = params.pop("soniox_cleanup", SONIOX_DEFAULT_CLEANUP)
if cleanup_raw is None:
cleanup: List[str] = []
elif isinstance(cleanup_raw, str):
cleanup = [cleanup_raw]
else:
cleanup = list(cleanup_raw)
filename_override = params.pop("filename", None)
# Server-side clamps. Caller-supplied poll settings (from request kwargs)
# are bounded so an authenticated caller cannot force a worker into a
# tight poll loop (zero interval) or pin it indefinitely (huge attempt
# count). Total polling time is bounded by
# SONIOX_MAX_POLL_ATTEMPTS * SONIOX_MAX_POLL_INTERVAL.
if not math.isfinite(poll_interval):
poll_interval = SONIOX_DEFAULT_POLL_INTERVAL
clamped_poll_interval = max(
SONIOX_MIN_POLL_INTERVAL, min(poll_interval, SONIOX_MAX_POLL_INTERVAL)
)
clamped_max_attempts = max(1, min(max_attempts, SONIOX_MAX_POLL_ATTEMPTS))
handler_opts: Dict[str, Any] = {
"poll_interval": clamped_poll_interval,
"max_attempts": clamped_max_attempts,
"cleanup": cleanup,
"filename_override": filename_override,
"audio_url": params.pop("audio_url", None),
"file_id": params.pop("file_id", None),
}
# Soniox does not accept `language` directly; map_openai_params should
# already have translated it, but drop any leftover to be safe.
params.pop("language", None)
# response_format is handled by LiteLLM post-processing, not Soniox.
handler_opts["response_format"] = params.pop("response_format", None)
return auth_headers, base_url, params, handler_opts
def _build_create_body(
self,
model: str,
optional_params: dict,
handler_opts: Dict[str, Any],
file_id: Optional[str],
) -> Dict[str, Any]:
body: Dict[str, Any] = {"model": model}
# Soniox-native passthrough fields
for key, value in optional_params.items():
if value is None:
continue
body[key] = value
if handler_opts.get("audio_url"):
body["audio_url"] = handler_opts["audio_url"]
if file_id:
body["file_id"] = file_id
return body
@staticmethod
def _redact_body_for_logging(body: Dict[str, Any]) -> Dict[str, Any]:
"""Return a shallow copy of ``body`` with secret fields redacted.
Soniox's create-transcription body can include
``webhook_auth_header_value`` (a shared secret used to authenticate
webhook callbacks). Forwarding that value to logging callbacks would
let anyone with read access to those sinks forge webhook requests, so
we replace any value of a known secret-bearing field with the literal
``"[REDACTED]"`` before logging. Non-secret fields are passed through
unchanged.
"""
if not body:
return body
redacted = dict(body)
for field in SONIOX_SECRET_FIELDS:
if field in redacted and redacted[field] is not None:
redacted[field] = "[REDACTED]"
return redacted
@staticmethod
def _safe_log_pre_call(
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str],
api_base: str,
body: Dict[str, Any],
) -> None:
try:
logging_obj.pre_call(
input=None,
api_key=api_key,
additional_args={
"api_base": f"{api_base}/v1/transcriptions",
"atranscription": True,
"complete_input_dict": SonioxAudioTranscriptionHandler._redact_body_for_logging(
body
),
},
)
except Exception:
# Logging hooks are best-effort: a misbehaving callback or third-party
# observability integration must never break a real Soniox call.
pass
@staticmethod
def _safe_log_post_call(
logging_obj: LiteLLMLoggingObj,
audio_file: Optional[FileTypes],
api_key: Optional[str],
body: Dict[str, Any],
original_response: Any,
) -> None:
try:
logging_obj.post_call(
input=get_audio_file_name(audio_file) if audio_file else None,
api_key=api_key,
additional_args={
"complete_input_dict": SonioxAudioTranscriptionHandler._redact_body_for_logging(
body
)
},
original_response=original_response,
)
except Exception:
# Logging hooks are best-effort: a misbehaving callback or third-party
# observability integration must never break a real Soniox call.
pass
@staticmethod
def _raise_for_response(
response: httpx.Response,
provider_config: SonioxAudioTranscriptionConfig,
action: str,
) -> None:
if response.status_code >= 400:
try:
payload = response.json()
message = (
payload.get("error_message")
or payload.get("error")
or response.text
)
except Exception:
message = response.text
raise provider_config.get_error_class(
error_message=f"Soniox {action} failed (HTTP {response.status_code}): {message}",
status_code=response.status_code,
headers=response.headers,
)
# ------------------------------------------------------------------
# Sync flow
# ------------------------------------------------------------------
def _sync_audio_transcriptions(
self,
model: str,
audio_file: Optional[FileTypes],
optional_params: dict,
litellm_params: dict,
model_response: TranscriptionResponse,
timeout: float,
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str],
api_base: Optional[str],
client: Optional[HTTPHandler],
headers: Dict[str, Any],
provider_config: SonioxAudioTranscriptionConfig,
) -> TranscriptionResponse:
auth_headers, base_url, opt_params, handler_opts = self._prepare(
audio_file=audio_file,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=api_key,
api_base=api_base,
provider_config=provider_config,
headers=headers,
)
http_client = (
client
if isinstance(client, HTTPHandler)
else (
_get_httpx_client(
params={"ssl_verify": litellm_params.get("ssl_verify", None)},
)
)
)
file_id = handler_opts.get("file_id")
uploaded_file_id: Optional[str] = None
transcription_id: Optional[str] = None
try:
if not file_id and not handler_opts.get("audio_url"):
if audio_file is None:
raise SonioxException(
message=(
"Soniox transcription requires one of: a file argument, "
"an `audio_url` kwarg, or a `file_id` kwarg."
),
status_code=400,
headers=None,
)
uploaded_file_id = self._sync_upload_file(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
audio_file=audio_file,
filename_override=handler_opts.get("filename_override"),
timeout=timeout,
provider_config=provider_config,
)
file_id = uploaded_file_id
body = self._build_create_body(model, opt_params, handler_opts, file_id)
self._safe_log_pre_call(logging_obj, api_key, base_url, body)
create_resp = http_client.post(
url=f"{base_url}/v1/transcriptions",
headers=auth_headers,
json=body,
timeout=timeout,
)
self._raise_for_response(
create_resp, provider_config, "create transcription"
)
transcription_id = create_resp.json()["id"]
transcription_meta = self._sync_poll_until_completed(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
transcription_id=transcription_id,
poll_interval=handler_opts["poll_interval"],
max_attempts=handler_opts["max_attempts"],
timeout=timeout,
provider_config=provider_config,
)
transcript_resp = http_client.get(
url=f"{base_url}/v1/transcriptions/{transcription_id}/transcript",
headers=auth_headers,
timeout=timeout,
)
self._raise_for_response(
transcript_resp, provider_config, "fetch transcript"
)
transcript = transcript_resp.json()
payload = {"transcription": transcription_meta, "transcript": transcript}
response = provider_config._build_response_from_payload(
payload,
model_response=model_response,
response_format=handler_opts.get("response_format"),
)
self._safe_log_post_call(logging_obj, audio_file, api_key, body, payload)
audio_duration_ms = transcription_meta.get("audio_duration_ms")
response._hidden_params.update(
{
"model": model,
"custom_llm_provider": "soniox",
"audio_transcription_duration": (
float(audio_duration_ms) / 1000.0
if audio_duration_ms is not None
else None
),
}
)
return response
finally:
self._sync_cleanup(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
cleanup=handler_opts["cleanup"],
file_id_to_cleanup=uploaded_file_id,
transcription_id=transcription_id,
timeout=timeout,
)
def _sync_upload_file(
self,
http_client: HTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
audio_file: FileTypes,
filename_override: Optional[str],
timeout: float,
provider_config: SonioxAudioTranscriptionConfig,
) -> str:
processed = process_audio_file(audio_file)
filename = filename_override or processed.filename
files = {
"file": (filename, processed.file_content, processed.content_type),
}
# `Authorization` header is fine; httpx sets multipart Content-Type.
upload_headers = {"Authorization": auth_headers["Authorization"]}
resp = http_client.post(
url=f"{base_url}/v1/files",
headers=upload_headers,
files=files,
timeout=timeout,
)
self._raise_for_response(resp, provider_config, "upload file")
return resp.json()["id"]
def _sync_poll_until_completed(
self,
http_client: HTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
transcription_id: str,
poll_interval: float,
max_attempts: int,
timeout: float,
provider_config: SonioxAudioTranscriptionConfig,
) -> Dict[str, Any]:
for _ in range(max_attempts):
resp = http_client.get(
url=f"{base_url}/v1/transcriptions/{transcription_id}",
headers=auth_headers,
timeout=timeout,
)
self._raise_for_response(resp, provider_config, "poll transcription")
data = resp.json()
status = data.get("status")
if status == "completed":
return data
if status == "error":
raise provider_config.get_error_class(
error_message=(
f"Soniox transcription {transcription_id} failed: "
f"{data.get('error_message') or data.get('error_type') or 'unknown error'}"
),
status_code=500,
headers=resp.headers,
)
time.sleep(poll_interval)
raise provider_config.get_error_class(
error_message=(
f"Soniox transcription {transcription_id} did not complete after "
f"{max_attempts} polling attempts (interval={poll_interval}s)."
),
status_code=504,
headers={},
)
def _sync_cleanup(
self,
http_client: HTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
cleanup: List[str],
file_id_to_cleanup: Optional[str],
transcription_id: Optional[str],
timeout: float,
) -> None:
if not cleanup:
return
if "transcription" in cleanup and transcription_id:
try:
http_client.delete(
url=f"{base_url}/v1/transcriptions/{transcription_id}",
headers=auth_headers,
timeout=timeout,
)
except Exception:
# Cleanup is best-effort: a failed delete leaves stale data on
# Soniox but must not mask the original transcription result
# (or, on the error path, the original error).
pass
if "file" in cleanup and file_id_to_cleanup:
try:
http_client.delete(
url=f"{base_url}/v1/files/{file_id_to_cleanup}",
headers=auth_headers,
timeout=timeout,
)
except Exception:
# Cleanup is best-effort; see comment above.
pass
# ------------------------------------------------------------------
# Async flow
# ------------------------------------------------------------------
async def _async_audio_transcriptions(
self,
model: str,
audio_file: Optional[FileTypes],
optional_params: dict,
litellm_params: dict,
model_response: TranscriptionResponse,
timeout: float,
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str],
api_base: Optional[str],
client: Optional[AsyncHTTPHandler],
headers: Dict[str, Any],
provider_config: SonioxAudioTranscriptionConfig,
) -> TranscriptionResponse:
import litellm
auth_headers, base_url, opt_params, handler_opts = self._prepare(
audio_file=audio_file,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=api_key,
api_base=api_base,
provider_config=provider_config,
headers=headers,
)
http_client = (
client
if isinstance(client, AsyncHTTPHandler)
else (
get_async_httpx_client(
llm_provider=litellm.LlmProviders.SONIOX,
params={"ssl_verify": litellm_params.get("ssl_verify", None)},
)
)
)
file_id = handler_opts.get("file_id")
uploaded_file_id: Optional[str] = None
transcription_id: Optional[str] = None
try:
if not file_id and not handler_opts.get("audio_url"):
if audio_file is None:
raise SonioxException(
message=(
"Soniox transcription requires one of: a file argument, "
"an `audio_url` kwarg, or a `file_id` kwarg."
),
status_code=400,
headers=None,
)
uploaded_file_id = await self._async_upload_file(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
audio_file=audio_file,
filename_override=handler_opts.get("filename_override"),
timeout=timeout,
provider_config=provider_config,
)
file_id = uploaded_file_id
body = self._build_create_body(model, opt_params, handler_opts, file_id)
self._safe_log_pre_call(logging_obj, api_key, base_url, body)
create_resp = await http_client.post(
url=f"{base_url}/v1/transcriptions",
headers=auth_headers,
json=body,
timeout=timeout,
)
self._raise_for_response(
create_resp, provider_config, "create transcription"
)
transcription_id = create_resp.json()["id"]
transcription_meta = await self._async_poll_until_completed(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
transcription_id=transcription_id,
poll_interval=handler_opts["poll_interval"],
max_attempts=handler_opts["max_attempts"],
timeout=timeout,
provider_config=provider_config,
)
transcript_resp = await http_client.get(
url=f"{base_url}/v1/transcriptions/{transcription_id}/transcript",
headers=auth_headers,
timeout=timeout,
)
self._raise_for_response(
transcript_resp, provider_config, "fetch transcript"
)
transcript = transcript_resp.json()
payload = {"transcription": transcription_meta, "transcript": transcript}
response = provider_config._build_response_from_payload(
payload,
model_response=model_response,
response_format=handler_opts.get("response_format"),
)
self._safe_log_post_call(logging_obj, audio_file, api_key, body, payload)
audio_duration_ms = transcription_meta.get("audio_duration_ms")
response._hidden_params.update(
{
"model": model,
"custom_llm_provider": "soniox",
"audio_transcription_duration": (
float(audio_duration_ms) / 1000.0
if audio_duration_ms is not None
else None
),
}
)
return response
finally:
await self._async_cleanup(
http_client=http_client,
base_url=base_url,
auth_headers=auth_headers,
cleanup=handler_opts["cleanup"],
file_id_to_cleanup=uploaded_file_id,
transcription_id=transcription_id,
timeout=timeout,
)
async def _async_upload_file(
self,
http_client: AsyncHTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
audio_file: FileTypes,
filename_override: Optional[str],
timeout: float,
provider_config: SonioxAudioTranscriptionConfig,
) -> str:
processed = process_audio_file(audio_file)
filename = filename_override or processed.filename
files = {
"file": (filename, processed.file_content, processed.content_type),
}
upload_headers = {"Authorization": auth_headers["Authorization"]}
resp = await http_client.post(
url=f"{base_url}/v1/files",
headers=upload_headers,
files=files,
timeout=timeout,
)
self._raise_for_response(resp, provider_config, "upload file")
return resp.json()["id"]
async def _async_poll_until_completed(
self,
http_client: AsyncHTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
transcription_id: str,
poll_interval: float,
max_attempts: int,
timeout: float,
provider_config: SonioxAudioTranscriptionConfig,
) -> Dict[str, Any]:
for _ in range(max_attempts):
resp = await http_client.get(
url=f"{base_url}/v1/transcriptions/{transcription_id}",
headers=auth_headers,
timeout=timeout,
)
self._raise_for_response(resp, provider_config, "poll transcription")
data = resp.json()
status = data.get("status")
if status == "completed":
return data
if status == "error":
raise provider_config.get_error_class(
error_message=(
f"Soniox transcription {transcription_id} failed: "
f"{data.get('error_message') or data.get('error_type') or 'unknown error'}"
),
status_code=500,
headers=resp.headers,
)
await asyncio.sleep(poll_interval)
raise provider_config.get_error_class(
error_message=(
f"Soniox transcription {transcription_id} did not complete after "
f"{max_attempts} polling attempts (interval={poll_interval}s)."
),
status_code=504,
headers={},
)
async def _async_cleanup(
self,
http_client: AsyncHTTPHandler,
base_url: str,
auth_headers: Dict[str, str],
cleanup: List[str],
file_id_to_cleanup: Optional[str],
transcription_id: Optional[str],
timeout: float,
) -> None:
if not cleanup:
return
if "transcription" in cleanup and transcription_id:
try:
await http_client.delete(
url=f"{base_url}/v1/transcriptions/{transcription_id}",
headers=auth_headers,
timeout=timeout,
)
except Exception:
# Cleanup is best-effort: a failed delete leaves stale data on
# Soniox but must not mask the original transcription result
# (or, on the error path, the original error).
pass
if "file" in cleanup and file_id_to_cleanup:
try:
await http_client.delete(
url=f"{base_url}/v1/files/{file_id_to_cleanup}",
headers=auth_headers,
timeout=timeout,
)
except Exception:
# Cleanup is best-effort; see comment above.
pass

View file

@ -0,0 +1,281 @@
"""
Translates between OpenAI's `/v1/audio/transcriptions` shape and Soniox's
async transcription API (https://soniox.com/docs/stt/async/async-transcription).
This config covers parameter mapping, env validation and response shaping.
The actual orchestration (file upload -> create -> poll -> fetch -> cleanup)
lives in `litellm.llms.soniox.audio_transcription.handler`, because Soniox's
async API requires multiple HTTP calls and does not fit the single-request
contract of `base_llm_http_handler.audio_transcriptions`.
"""
from typing import Any, Dict, List, Optional, Union
from httpx import Headers, Response
from litellm.llms.base_llm.audio_transcription.transformation import (
AudioTranscriptionRequestData,
BaseAudioTranscriptionConfig,
)
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.soniox.common_utils import (
SonioxException,
get_soniox_api_base,
get_soniox_api_key,
render_soniox_tokens,
render_soniox_tokens_as_srt,
render_soniox_tokens_as_vtt,
)
from litellm.types.llms.openai import (
AllMessageValues,
OpenAIAudioTranscriptionOptionalParams,
)
from litellm.types.utils import FileTypes, TranscriptionResponse
# Soniox-native kwargs the user can pass through `litellm.transcription(..., **kwargs)`
# in addition to the standard OpenAI params.
SONIOX_PASSTHROUGH_PARAMS: List[str] = [
"language_hints",
"language_hints_strict",
"enable_language_identification",
"enable_speaker_diarization",
"context",
"translation",
"client_reference_id",
"webhook_url",
"webhook_auth_header_name",
"webhook_auth_header_value",
"audio_url",
"file_id",
]
# Handler-only kwargs (consumed by the handler, not sent to Soniox).
SONIOX_HANDLER_ONLY_PARAMS: List[str] = [
"soniox_polling_interval",
"soniox_max_polling_attempts",
"soniox_cleanup",
"filename",
]
class SonioxAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
"""Configuration for Soniox async speech-to-text transcription."""
def get_supported_openai_params(
self, model: str
) -> List[OpenAIAudioTranscriptionOptionalParams]:
# `language` is mapped onto Soniox's `language_hints`.
# `response_format` is handled by LiteLLM (Soniox doesn't support
# SRT/VTT natively but we synthesize them from token timestamps).
return ["language", "response_format"]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
# Translate the OpenAI `language` param into Soniox `language_hints`.
if "language" in non_default_params and non_default_params["language"]:
language = non_default_params["language"]
existing_hints = optional_params.get("language_hints")
if not existing_hints:
optional_params["language_hints"] = [language]
elif language not in existing_hints:
optional_params["language_hints"] = [language] + list(existing_hints)
# Capture response_format for post-processing (not sent to Soniox API).
if "response_format" in non_default_params:
optional_params["response_format"] = non_default_params["response_format"]
# Pass through Soniox-native kwargs unchanged.
for key in SONIOX_PASSTHROUGH_PARAMS + SONIOX_HANDLER_ONLY_PARAMS:
if key in non_default_params and non_default_params[key] is not None:
optional_params[key] = non_default_params[key]
return optional_params
def get_error_class(
self, error_message: str, status_code: int, headers: Union[dict, Headers]
) -> BaseLLMException:
return SonioxException(
message=error_message, status_code=status_code, headers=headers
)
def validate_environment(
self,
headers: dict,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
resolved_key = get_soniox_api_key(api_key)
if not resolved_key:
raise SonioxException(
message=(
"Missing Soniox API key. Set the SONIOX_API_KEY environment "
"variable or pass api_key=... to litellm.transcription()."
),
status_code=401,
headers=None,
)
merged_headers: Dict[str, str] = {
"Authorization": f"Bearer {resolved_key}",
}
if headers:
merged_headers.update(headers)
return merged_headers
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
# The handler builds per-call URLs (uploads, create, poll, fetch, delete);
# we just return the resolved base.
return get_soniox_api_base(api_base)
def transform_audio_transcription_request(
self,
model: str,
audio_file: FileTypes,
optional_params: dict,
litellm_params: dict,
) -> AudioTranscriptionRequestData:
"""
Build the JSON body for `POST /v1/transcriptions`.
The handler is responsible for the file upload (if `audio_file` is bytes)
and for filling in `file_id`/`audio_url`. This method exists so the
config can be exercised in isolation by unit tests.
"""
body: Dict[str, Any] = {"model": model}
for key in SONIOX_PASSTHROUGH_PARAMS:
value = optional_params.get(key)
if value is not None:
body[key] = value
return AudioTranscriptionRequestData(
data=body, files=None, content_type="application/json"
)
def transform_audio_transcription_response(
self,
raw_response: Response,
model_response: Optional[TranscriptionResponse] = None,
) -> TranscriptionResponse:
"""
Build a TranscriptionResponse from a Soniox transcript payload.
`raw_response.json()` may be either:
- a Soniox transcript object: `{"id": "...", "text": "...", "tokens": [...]}`
- or a merged envelope: `{"transcription": {...}, "transcript": {...}}`
produced by the handler so transcription metadata is also available.
"""
try:
payload = raw_response.json()
except Exception as exc:
raise SonioxException(
message=f"Failed to parse Soniox response: {exc}",
status_code=getattr(raw_response, "status_code", 500),
headers=getattr(raw_response, "headers", None),
)
return self._build_response_from_payload(payload, model_response=model_response)
def _build_response_from_payload(
self,
payload: Dict[str, Any],
model_response: Optional[TranscriptionResponse] = None,
response_format: Optional[str] = None,
) -> TranscriptionResponse:
"""Shared response-building logic (also used by the handler)."""
transcription_meta: Dict[str, Any] = {}
transcript: Dict[str, Any]
if isinstance(payload, dict) and "transcript" in payload:
transcription_meta = payload.get("transcription") or {}
transcript = payload.get("transcript") or {}
else:
transcript = payload if isinstance(payload, dict) else {}
tokens: List[Dict[str, Any]] = transcript.get("tokens") or []
# Decide what to put in `text` based on response_format:
# - "srt": render tokens as SRT subtitles (synthesized from timestamps)
# - "vtt": render tokens as WebVTT subtitles (synthesized from timestamps)
# - "verbose_json": return JSON with word-level timing (handled below)
# - "text" / "json" / None: default plain text rendering
if response_format == "srt" and tokens:
text = render_soniox_tokens_as_srt(tokens)
elif response_format == "vtt" and tokens:
text = render_soniox_tokens_as_vtt(tokens)
else:
# Default text rendering (also used for "json", "text",
# "verbose_json")
has_speaker = any(t.get("speaker") is not None for t in tokens)
has_language = any(t.get("language") is not None for t in tokens)
if (has_speaker or has_language) and tokens:
text = render_soniox_tokens(tokens)
elif transcript.get("text"):
text = transcript["text"]
elif tokens:
text = "".join(t.get("text", "") for t in tokens)
else:
text = ""
response = model_response or TranscriptionResponse(text=text)
response.text = text
response["task"] = "transcribe"
# Best-effort metadata fields matching OpenAI's verbose_json shape.
if transcription_meta.get("audio_duration_ms") is not None:
try:
response["duration"] = (
float(transcription_meta["audio_duration_ms"]) / 1000.0
)
except (TypeError, ValueError):
pass
# Surface a representative language if all tokens agree.
has_language = any(t.get("language") is not None for t in tokens)
if has_language:
languages = {t.get("language") for t in tokens if t.get("language")}
if len(languages) == 1:
response["language"] = next(iter(languages))
# For verbose_json, include word-level timing from tokens.
if response_format == "verbose_json" and tokens:
words: List[Dict[str, Any]] = []
for token in tokens:
word_entry: Dict[str, Any] = {"word": token.get("text", "")}
if token.get("start_ms") is not None:
word_entry["start"] = float(token["start_ms"]) / 1000.0
if token.get("end_ms") is not None:
word_entry["end"] = float(token["end_ms"]) / 1000.0
words.append(word_entry)
if words:
response["words"] = words
# Stash the raw Soniox payload so power-users can read tokens, segments,
# speaker/language data, etc.
response._hidden_params.update(
{
"soniox_raw": {
"transcription": transcription_meta,
"transcript": transcript,
}
}
)
return response

View file

@ -0,0 +1,274 @@
"""
Shared utilities for the Soniox provider (https://soniox.com).
"""
from typing import Any, Dict, List, Optional
from litellm.llms.base_llm.chat.transformation import BaseLLMException
# Soniox API base URL.
SONIOX_API_BASE: str = "https://api.soniox.com"
# Default polling interval in seconds when waiting for an async transcription
# to finish. Mirrors the Soniox SDK default.
SONIOX_DEFAULT_POLL_INTERVAL: float = 1.0
# Minimum polling interval (in seconds) the server will accept from caller-
# supplied `soniox_polling_interval` kwargs. Prevents an authenticated caller
# from forcing a worker into a tight poll loop with a zero/near-zero interval.
SONIOX_MIN_POLL_INTERVAL: float = 0.5
# Maximum polling interval (in seconds). Prevents a caller from setting an
# excessively large or non-finite interval that would keep a worker sleeping
# far longer than necessary between status checks.
SONIOX_MAX_POLL_INTERVAL: float = 60.0
# Default maximum number of polling attempts (1800 attempts * 1s ~= 30 minutes).
SONIOX_DEFAULT_MAX_POLL_ATTEMPTS: int = 1800
# Hard upper bound on polling attempts. Combined with `SONIOX_MIN_POLL_INTERVAL`
# this caps total polling time per request at ~3000s (50 minutes), preventing a
# caller from pinning a worker indefinitely via a huge attempt count.
SONIOX_MAX_POLL_ATTEMPTS: int = 6000
# Default cleanup behaviour: delete both the uploaded file (if any) and the
# transcription record after the transcript has been fetched.
SONIOX_DEFAULT_CLEANUP: List[str] = ["file", "transcription"]
# Body fields that may carry secrets and must be redacted before being
# forwarded to logging callbacks. Soniox accepts a webhook auth header value
# alongside the create-transcription request; that value lets the recipient
# authenticate webhook callbacks and must not leak into observability sinks.
SONIOX_SECRET_FIELDS: List[str] = ["webhook_auth_header_value"]
class SonioxException(BaseLLMException):
"""Provider-specific exception class for Soniox."""
pass
def get_soniox_api_key(api_key: Optional[str] = None) -> Optional[str]:
"""Resolve the Soniox API key from arg or env var."""
# Local import to avoid a circular import: litellm.secret_managers.main
# imports from litellm at top-level.
from litellm.secret_managers.main import get_secret_str
return api_key or get_secret_str("SONIOX_API_KEY")
def get_soniox_api_base(api_base: Optional[str] = None) -> str:
"""Resolve the Soniox API base URL from arg or env var (defaults to public API)."""
from litellm.secret_managers.main import get_secret_str
base = api_base or get_secret_str("SONIOX_API_BASE") or SONIOX_API_BASE
return base.rstrip("/")
def render_soniox_tokens(tokens: List[Dict[str, Any]]) -> str:
"""
Render a list of Soniox tokens to a readable transcript string.
Mirrors the behaviour of the official Soniox SDK's `renderTokens` helper:
- When the speaker changes, a `Speaker N:` tag is inserted.
- When the language changes, a `[lang]` (or `[Translation][lang]`) tag is
inserted.
If neither speaker nor language information is present on any token (i.e.
diarization and language identification are disabled), the function simply
concatenates the token texts.
"""
if not tokens:
return ""
text_parts: List[str] = []
current_speaker: Optional[Any] = None
current_language: Optional[Any] = None
for token in tokens:
text = token.get("text", "")
speaker = token.get("speaker")
language = token.get("language")
is_translation = token.get("translation_status") == "translation"
# Speaker changed -> emit a speaker tag.
if speaker is not None and speaker != current_speaker:
if current_speaker is not None:
text_parts.append("\n\n")
current_speaker = speaker
current_language = None # reset language whenever speaker changes
text_parts.append(f"Speaker {current_speaker}:")
# Language changed -> emit a language (or translation) tag.
if language is not None and language != current_language:
current_language = language
prefix = "[Translation] " if is_translation else ""
text_parts.append(f"\n{prefix}[{current_language}] ")
text = text.lstrip() if isinstance(text, str) else text
text_parts.append(text)
return "".join(text_parts)
# ---------------------------------------------------------------------------
# SRT / VTT subtitle rendering
# ---------------------------------------------------------------------------
# Maximum number of tokens to group into a single subtitle cue.
_CUE_MAX_TOKENS: int = 15
# Maximum duration (in ms) for a single cue before forcing a break.
_CUE_MAX_DURATION_MS: int = 5000
def _format_timestamp_srt(ms: int) -> str:
"""Format milliseconds as SRT timestamp: HH:MM:SS,mmm"""
if ms < 0:
ms = 0
hours = ms // 3_600_000
ms %= 3_600_000
minutes = ms // 60_000
ms %= 60_000
seconds = ms // 1_000
millis = ms % 1_000
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
def _format_timestamp_vtt(ms: int) -> str:
"""Format milliseconds as VTT timestamp: HH:MM:SS.mmm"""
if ms < 0:
ms = 0
hours = ms // 3_600_000
ms %= 3_600_000
minutes = ms // 60_000
ms %= 60_000
seconds = ms // 1_000
millis = ms % 1_000
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
def _group_tokens_into_cues(
tokens: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""
Group Soniox tokens into subtitle cues.
Each cue has:
- start_ms: int
- end_ms: int
- text: str
Grouping heuristics:
- A new cue starts when token count exceeds _CUE_MAX_TOKENS.
- A new cue starts when duration exceeds _CUE_MAX_DURATION_MS.
- A new cue starts when the speaker changes (if diarization is on).
- Tokens without timestamps are appended to the current cue.
"""
cues: List[Dict[str, Any]] = []
current_tokens: List[str] = []
current_start: Optional[int] = None
current_end: Optional[int] = None
current_speaker: Optional[Any] = None
def _flush() -> None:
if current_tokens and current_start is not None:
text = "".join(current_tokens).strip()
if text:
cues.append(
{
"start_ms": current_start,
"end_ms": (
current_end if current_end is not None else current_start
),
"text": text,
}
)
for token in tokens:
start_ms = token.get("start_ms")
end_ms = token.get("end_ms")
text = token.get("text", "")
speaker = token.get("speaker")
# Skip tokens with no timestamp data entirely if we have no cue started
if start_ms is None and current_start is None:
continue
# Speaker change forces a new cue
if speaker is not None and speaker != current_speaker:
_flush()
current_tokens = []
current_start = start_ms
current_end = end_ms
current_speaker = speaker
current_tokens.append(text)
continue
# Duration or token count exceeded -> flush
should_break = False
if len(current_tokens) >= _CUE_MAX_TOKENS:
should_break = True
elif (
current_start is not None
and start_ms is not None
and (start_ms - current_start) >= _CUE_MAX_DURATION_MS
):
should_break = True
if should_break:
_flush()
current_tokens = []
current_start = start_ms
current_end = end_ms
current_tokens.append(text)
else:
if current_start is None:
current_start = start_ms
if end_ms is not None:
current_end = end_ms
current_tokens.append(text)
_flush()
return cues
def render_soniox_tokens_as_srt(tokens: List[Dict[str, Any]]) -> str:
"""
Render Soniox tokens as SRT (SubRip) subtitle format.
Returns an empty string if no tokens have timestamp data.
"""
cues = _group_tokens_into_cues(tokens)
if not cues:
return ""
lines: List[str] = []
for idx, cue in enumerate(cues, start=1):
start = _format_timestamp_srt(cue["start_ms"])
end = _format_timestamp_srt(cue["end_ms"])
lines.append(str(idx))
lines.append(f"{start} --> {end}")
lines.append(cue["text"])
lines.append("") # blank line between cues
return "\n".join(lines)
def render_soniox_tokens_as_vtt(tokens: List[Dict[str, Any]]) -> str:
"""
Render Soniox tokens as WebVTT subtitle format.
Returns the VTT header even if no cues are present.
"""
cues = _group_tokens_into_cues(tokens)
lines: List[str] = ["WEBVTT", ""]
for cue in cues:
start = _format_timestamp_vtt(cue["start_ms"])
end = _format_timestamp_vtt(cue["end_ms"])
lines.append(f"{start} --> {end}")
lines.append(cue["text"])
lines.append("") # blank line between cues
return "\n".join(lines)

View file

@ -337,6 +337,7 @@ class ContextCachingEndpoints(VertexBase):
return messages, optional_params, None
tools = optional_params.pop("tools", None)
tool_choice = optional_params.pop("tool_choice", None)
## AUTHORIZATION ##
token, url = self._get_token_and_url_context_caching(
@ -371,7 +372,7 @@ class ContextCachingEndpoints(VertexBase):
## CHECK IF CACHED ALREADY
generated_cache_key = local_cache_obj.get_cache_key(
messages=cached_messages, tools=tools, model=model
messages=cached_messages, tools=tools, tool_choice=tool_choice, model=model
)
google_cache_name = self.check_cache(
cache_key=generated_cache_key,
@ -402,6 +403,8 @@ class ContextCachingEndpoints(VertexBase):
)
cached_content_request_body["tools"] = tools
if tool_choice is not None:
cached_content_request_body["toolConfig"] = tool_choice
## LOGGING
logging_obj.pre_call(
@ -487,6 +490,7 @@ class ContextCachingEndpoints(VertexBase):
return messages, optional_params, None
tools = optional_params.pop("tools", None)
tool_choice = optional_params.pop("tool_choice", None)
## AUTHORIZATION ##
token, url = self._get_token_and_url_context_caching(
@ -518,7 +522,7 @@ class ContextCachingEndpoints(VertexBase):
## CHECK IF CACHED ALREADY
generated_cache_key = local_cache_obj.get_cache_key(
messages=cached_messages, tools=tools, model=model
messages=cached_messages, tools=tools, tool_choice=tool_choice, model=model
)
google_cache_name = await self.async_check_cache(
cache_key=generated_cache_key,
@ -550,6 +554,8 @@ class ContextCachingEndpoints(VertexBase):
)
cached_content_request_body["tools"] = tools
if tool_choice is not None:
cached_content_request_body["toolConfig"] = tool_choice
## LOGGING
logging_obj.pre_call(

View file

@ -996,7 +996,19 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
excluded_keys=["thoughtSignature"],
):
assistant_content.append(gemini_tool_call_part)
last_message_with_tool_calls = assistant_msg
# Only record this as the active tool-call message when it actually
# carries tool calls. The `if` guard above is also entered for a
# text-only assistant message (`assistant_msg.get("tool_calls", [])
# is not None` is True for an empty list), so without this check a
# later assistant message with no tool calls would clobber the
# reference. The following tool result would then be matched against
# an assistant message that has no tool_calls, raising "Missing
# corresponding tool call for tool response message".
if (
assistant_msg.get("tool_calls")
or assistant_msg.get("function_call") is not None
):
last_message_with_tool_calls = assistant_msg
## HANDLE SERVER-SIDE TOOL INVOCATIONS (context circulation)
_psf = assistant_msg.get("provider_specific_fields")
@ -1109,6 +1121,61 @@ def _pop_and_merge_extra_body(data: RequestBody, optional_params: dict) -> None:
data_dict[k] = v
def _has_google_maps_tool(tools: Optional[Any]) -> bool:
"""Return True if any tool object in the list has a 'googleMaps' key."""
if not isinstance(tools, list):
return False
return any(
isinstance(t, dict) and VertexToolName.GOOGLE_MAPS.value in t for t in tools
)
def _rewrite_mime_type_to_response_format(generation_config: GenerationConfig) -> None:
"""
Convert response_mime_type + response_json_schema/response_schema to the newer
responseFormat structure when googleMaps is present in tools.
The Gemini API rejects the combination of googleMaps + response_mime_type:
'application/json' with the error:
"Google Maps tool with a response mime type: 'application/json' is unsupported"
The newer responseFormat field supports this combination on both the Gemini API
(generativelanguage.googleapis.com) and Vertex AI endpoints.
Before:
generationConfig: {
response_mime_type: "application/json",
response_json_schema: {...}
}
After:
generationConfig: {
responseFormat: {
"text": {"mimeType": "APPLICATION_JSON", "schema": {...}}
}
}
"""
schema = generation_config.pop("response_json_schema", None) # type: ignore[misc]
if schema is None:
schema = generation_config.pop("response_schema", None) # type: ignore[misc]
generation_config.pop("response_mime_type", None) # type: ignore[misc]
response_format: Dict[str, Any] = {"text": {"mimeType": "APPLICATION_JSON"}}
if schema is not None:
response_format["text"]["schema"] = schema
generation_config["responseFormat"] = response_format # type: ignore[typeddict-unknown-key]
def _rewrite_google_maps_response_format(data: RequestBody) -> None:
generation_config = cast(Optional[GenerationConfig], data.get("generationConfig"))
if (
isinstance(generation_config, dict)
and _has_google_maps_tool(data.get("tools"))
and generation_config.get("response_mime_type") == "application/json"
):
_rewrite_mime_type_to_response_format(generation_config)
def _transform_request_body( # noqa: PLR0915
messages: List[AllMessageValues],
model: str,
@ -1234,6 +1301,7 @@ def _transform_request_body( # noqa: PLR0915
if labels and custom_llm_provider != LlmProviders.GEMINI:
data["labels"] = labels
_pop_and_merge_extra_body(data, optional_params)
_rewrite_google_maps_response_format(data)
except Exception as e:
raise e

View file

@ -1147,6 +1147,26 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
return cast(dict, speech_config)
@staticmethod
def _apply_include_server_side_tool_invocations(
non_default_params: Dict,
optional_params: Dict,
) -> None:
"""
Set include_server_side_tool_invocations before tools are mapped.
map_openai_params iterates non_default_params in request order; if tools
appear before this flag, _resolve_search_tool_conflict would drop search
tools before the flag is applied.
"""
for key in (
"include_server_side_tool_invocations",
"includeServerSideToolInvocations",
):
if non_default_params.get(key) is True or optional_params.get(key) is True:
optional_params["include_server_side_tool_invocations"] = True
return
def map_openai_params( # noqa: PLR0915
self,
non_default_params: Dict,
@ -1154,6 +1174,9 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
model: str,
drop_params: bool,
) -> Dict:
self._apply_include_server_side_tool_invocations(
non_default_params, optional_params
)
gemini_sampling_params_warned: bool = False
for param, value in non_default_params.items():
if param == "temperature":

View file

@ -159,6 +159,6 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert
"model", None
) # do not pass model in request body to vertex ai
sanitize_vertex_anthropic_output_params(anthropic_messages_request)
sanitize_vertex_anthropic_output_params(anthropic_messages_request, model)
return anthropic_messages_request

View file

@ -10,23 +10,38 @@ import; extracting the helper into a leaf module resolves the warning and
keeps the parent module's import surface narrow.
"""
# Keys inside ``output_config`` that Vertex AI Claude does not accept.
# Add an entry only when a 400 "Extra inputs are not permitted" is
# reproducible against the live Vertex endpoint.
# Keys inside ``output_config`` that Vertex AI Claude rejects regardless of
# the target model. Add an entry only when a 400 "Extra inputs are not
# permitted" is reproducible against the live Vertex endpoint for every model.
VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS: frozenset = frozenset()
def sanitize_vertex_anthropic_output_params(data: dict) -> None:
def _model_accepts_output_config_effort(model: str) -> bool:
"""Whether ``model`` accepts ``output_config.effort`` on Vertex.
Opus/Sonnet 4.6+ advertise ``supports_output_config`` (or a reasoning
effort level) and accept it; Haiku 4.5 advertises neither and 400s on
``output_config.effort: Extra inputs are not permitted``. Imported lazily
so this stays a leaf module (see module docstring).
"""
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
return AnthropicConfig._model_supports_effort_param(model)
def sanitize_vertex_anthropic_output_params(data: dict, model: str) -> None:
"""
Strip Vertex-unsupported keys from ``output_config`` /
``output_format`` in-place; forward whatever remains.
Behavior:
* ``output_config`` containing only unsupported keys (e.g. ``effort``
alone) is removed entirely so the request body has no empty dict.
* ``output_config`` containing a mix of supported + unsupported keys
has the unsupported subset filtered out and the rest forwarded.
* ``output_config`` that is supported in full passes through unchanged.
* ``output_config.effort`` is dropped for models that don't accept it
(e.g. Haiku 4.5) and forwarded for those that do (Opus/Sonnet 4.6+).
Clients like Claude Code inject it into every Messages payload, so the
gate has to live here rather than rely on the caller.
* Keys in ``VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS`` are always filtered.
* ``output_config`` left empty after filtering is removed so the request
body has no empty dict.
* ``output_format`` is forwarded as-is (Vertex AI Claude accepts it).
* Non-dict values for ``output_config`` are dropped to avoid sending
malformed payloads downstream.
@ -37,11 +52,19 @@ def sanitize_vertex_anthropic_output_params(data: dict) -> None:
if not isinstance(output_config, dict):
data.pop("output_config", None)
return
sanitized = {
k: v
for k, v in output_config.items()
if k not in VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS
}
drop_keys = set(VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS)
if "effort" in output_config and not _model_accepts_output_config_effort(model):
from litellm._logging import verbose_logger
verbose_logger.debug(
"Dropping unsupported output_config.effort for vertex_ai model=%s "
"(no supports_output_config in the model map)",
model,
)
drop_keys.add("effort")
sanitized = {k: v for k, v in output_config.items() if k not in drop_keys}
if sanitized:
data["output_config"] = sanitized
else:

View file

@ -106,7 +106,7 @@ class VertexAIAnthropicConfig(AnthropicConfig):
data.pop("model", None) # vertex anthropic doesn't accept 'model' parameter
sanitize_vertex_anthropic_output_params(data)
sanitize_vertex_anthropic_output_params(data, model)
tools = optional_params.get("tools")
tool_search_used = self.is_tool_search_used(tools)

View file

View file

@ -0,0 +1,7 @@
"""
You.com Search API module.
"""
from litellm.llms.you_com.search.transformation import YouComSearchConfig
__all__ = ["YouComSearchConfig"]

View file

@ -0,0 +1,193 @@
"""
Calls You.com's /v1/search endpoint to search the web.
You.com API Reference: https://you.com/docs/api-reference/search/v1-search
OpenAPI spec: https://you.com/specs/openapi_search_v1.yaml
"""
from typing import Dict, List, Optional, TypedDict, Union
import httpx
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str
class _YouComSearchRequestRequired(TypedDict):
"""Required fields for You.com Search API request."""
query: str
class YouComSearchRequest(_YouComSearchRequestRequired, total=False):
"""
You.com Search API request format.
Based on: https://you.com/specs/openapi_search_v1.yaml
"""
count: int
country: str
language: str
freshness: str
include_domains: List[str]
exclude_domains: List[str]
safesearch: str
class YouComSearchConfig(BaseSearchConfig):
# Keyed tier (higher rate limits): authenticate with X-API-Key.
YOU_COM_API_BASE = "https://ydc-index.io"
# Keyless free tier: IP-throttled (100 queries/day) and requires no auth.
# Used automatically when YOUCOM_API_KEY is not set.
YOU_COM_FREE_API_BASE = "https://api.you.com/v1/agents/search"
@staticmethod
def ui_friendly_name() -> str:
return "You.com"
def validate_environment(
self,
headers: Dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Set headers for the You.com Search API.
If YOUCOM_API_KEY (or an explicit api_key) is present, use the keyed
endpoint with the `X-API-Key` header. Otherwise fall through to the
keyless free tier; no auth header is required.
"""
api_key = api_key or get_secret_str("YOUCOM_API_KEY")
headers["Content-Type"] = "application/json"
# Pin Accept-Encoding to identity: the keyless `api.you.com/v1/agents/search`
# endpoint advertises gzip content-encoding but returns body bytes the
# decoder rejects, which surfaces as httpx.DecodingError through litellm's
# http handler. Identity is harmless on the keyed endpoint.
headers.setdefault("Accept-Encoding", "identity")
if api_key:
headers["X-API-Key"] = api_key
return headers
def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[Dict, List[Dict]]] = None,
**kwargs,
) -> str:
"""
Pick the endpoint based on whether an API key is configured.
- api_base explicit override -> use it as-is (normalized)
- YOUCOM_API_KEY set -> keyed endpoint (ydc-index.io/v1/search)
- no key -> keyless free tier (api.you.com/v1/agents/search)
"""
if api_base is None:
api_base = get_secret_str("YOUCOM_API_BASE")
if api_base is None:
api_key = kwargs.get("api_key") or get_secret_str("YOUCOM_API_KEY")
if api_key:
api_base = self.YOU_COM_API_BASE
else:
# Keyless free tier already includes the full path.
return self.YOU_COM_FREE_API_BASE
api_base = api_base.rstrip("/")
if not api_base.endswith("/v1/search") and not api_base.endswith(
"/v1/agents/search"
):
api_base = f"{api_base}/v1/search"
return api_base
def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
**kwargs,
) -> Dict:
"""
Transform Search request to You.com API format.
Perplexity unified spec You.com mappings:
- query query
- max_results count
- search_domain_filter include_domains
- country country
- max_tokens_per_page (not applicable, ignored)
"""
if isinstance(query, list):
query = " ".join(query)
request_data: YouComSearchRequest = {
"query": query,
}
if "max_results" in optional_params:
request_data["count"] = optional_params["max_results"]
if "search_domain_filter" in optional_params:
request_data["include_domains"] = optional_params["search_domain_filter"]
if "country" in optional_params:
request_data["country"] = optional_params["country"].lower()
result_data = dict(request_data)
for param, value in optional_params.items():
if (
param not in self.get_supported_perplexity_optional_params()
and param not in result_data
):
result_data[param] = value
return result_data
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs,
) -> SearchResponse:
"""
Transform You.com API response to LiteLLM unified SearchResponse format.
You.com LiteLLM mappings (for both `results.web[]` and `results.news[]`):
- title SearchResult.title
- url SearchResult.url
- snippets[0] SearchResult.snippet (falls back to `description`)
- page_age SearchResult.date
"""
response_json = raw_response.json()
raw_results = response_json.get("results") or {}
web_results = raw_results.get("web") or []
news_results = raw_results.get("news") or []
results: List[SearchResult] = []
for item in list(web_results) + list(news_results):
snippets = item.get("snippets") or []
snippet = snippets[0] if snippets else item.get("description", "")
results.append(
SearchResult(
title=item.get("title", ""),
url=item.get("url", ""),
snippet=snippet,
date=item.get("page_age"),
last_updated=None,
)
)
return SearchResponse(
results=results,
object="search",
)

View file

@ -437,6 +437,7 @@ async def acompletion( # noqa: PLR0915
# Optional liteLLM function params
thinking: Optional[AnthropicThinkingParam] = None,
web_search_options: Optional[OpenAIWebSearchOptions] = None,
include_server_side_tool_invocations: Optional[bool] = None,
# Session management
shared_session: Optional["ClientSession"] = None,
# Per-request JSON schema validation (overrides litellm.enable_json_schema_validation)
@ -584,6 +585,7 @@ async def acompletion( # noqa: PLR0915
"acompletion": True, # assuming this is a required parameter
"thinking": thinking,
"web_search_options": web_search_options,
"include_server_side_tool_invocations": include_server_side_tool_invocations,
"shared_session": shared_session,
"enable_json_schema_validation": enable_json_schema_validation,
}
@ -641,6 +643,7 @@ async def acompletion( # noqa: PLR0915
if (
custom_llm_provider == "text-completion-openai"
or custom_llm_provider == "text-completion-codestral"
or custom_llm_provider == "text-completion-inception"
) and isinstance(response, TextCompletionResponse):
response = litellm.OpenAITextCompletionConfig().convert_to_chat_model_response_object(
response_object=response,
@ -1115,6 +1118,7 @@ def completion( # type: ignore # noqa: PLR0915
top_logprobs: Optional[int] = None,
parallel_tool_calls: Optional[bool] = None,
web_search_options: Optional[OpenAIWebSearchOptions] = None,
include_server_side_tool_invocations: Optional[bool] = None,
deployment_id=None,
extra_headers: Optional[dict] = None,
safety_identifier: Optional[str] = None,
@ -1318,7 +1322,9 @@ def completion( # type: ignore # noqa: PLR0915
preset_cache_key = kwargs.get("preset_cache_key", None)
hf_model_name = kwargs.get("hf_model_name", None)
supports_system_message = kwargs.get("supports_system_message", None)
base_model = kwargs.get("base_model", None)
base_model = kwargs.get("base_model", None) or (
model_info.get("base_model") if isinstance(model_info, dict) else None
)
### DISABLE FLAGS ###
disable_add_transform_inline_image_block = kwargs.get(
"disable_add_transform_inline_image_block", None
@ -1530,11 +1536,7 @@ def completion( # type: ignore # noqa: PLR0915
"logit_bias": logit_bias,
"user": user,
# params to identify the model
"model": (
model_info.get("base_model")
if isinstance(model_info, dict) and model_info.get("base_model")
else model
),
"model": model,
"custom_llm_provider": custom_llm_provider,
"response_format": response_format,
"seed": seed,
@ -1549,6 +1551,11 @@ def completion( # type: ignore # noqa: PLR0915
"reasoning_effort": reasoning_effort,
"thinking": thinking,
"web_search_options": web_search_options,
"include_server_side_tool_invocations": (
include_server_side_tool_invocations
if include_server_side_tool_invocations is not None
else kwargs.get("include_server_side_tool_invocations")
),
"safety_identifier": safety_identifier,
"service_tier": service_tier,
"allowed_openai_params": kwargs.get("allowed_openai_params"),
@ -3803,6 +3810,67 @@ def completion( # type: ignore # noqa: PLR0915
):
return _model_response
response = _model_response
elif custom_llm_provider == "text-completion-inception":
passed_api_base = (
api_base
or optional_params.pop("api_base", None)
or optional_params.pop("base_url", None)
)
api_base = (
passed_api_base
or get_secret_str("INCEPTION_API_BASE")
or "https://api.inceptionlabs.ai/v1"
)
# FIM is served at `/v1/fim/completions`; the OpenAI client appends
# `/completions`, so point it at the `/v1/fim` base.
api_base = api_base.rstrip("/")
if not api_base.endswith("/fim"):
api_base += "/fim"
# Don't forward the server-managed Inception key to a caller-supplied
# api_base; only resolve it for the default/server base, or when the
# caller passes their own key.
if passed_api_base is None or api_key:
api_key = (
api_key
or litellm.inception_key
or get_secret_str("INCEPTION_API_KEY")
)
_response = openai_text_completions.completion(
model=model,
messages=messages,
model_response=model_response,
print_verbose=print_verbose,
api_key=api_key, # type: ignore[arg-type]
custom_llm_provider="text-completion-inception",
api_base=api_base,
acompletion=acompletion,
client=client,
logging_obj=logging,
optional_params=optional_params,
litellm_params=litellm_params,
logger_fn=logger_fn,
timeout=timeout, # type: ignore
)
if (
optional_params.get("stream", False) is False
and acompletion is False
and text_completion is False
):
_response = litellm.OpenAITextCompletionConfig().convert_to_chat_model_response_object(
response_object=_response, model_response_object=model_response
)
if optional_params.get("stream", False) or acompletion is True:
logging.post_call(
input=messages,
api_key=api_key,
original_response=_response,
additional_args={"headers": headers},
)
response = _response
elif custom_llm_provider in ("sagemaker_chat", "sagemaker_nova"):
# boto3 reads keys from .env
# sagemaker_chat: HF Messages API endpoints
@ -6587,7 +6655,7 @@ async def atranscription(*args, **kwargs) -> TranscriptionResponse:
@client
def transcription(
def transcription( # noqa: PLR0915
model: str,
file: FileTypes,
## OPTIONAL OPENAI PARAMS ##
@ -6779,6 +6847,35 @@ def transcription(
else None
),
)
elif custom_llm_provider == "soniox":
from litellm.llms.soniox.audio_transcription.handler import (
SonioxAudioTranscriptionHandler,
)
response = SonioxAudioTranscriptionHandler().audio_transcriptions(
model=model,
audio_file=file,
optional_params=optional_params,
litellm_params=litellm_params_dict,
model_response=model_response,
atranscription=atranscription,
client=(
client
if client is not None
and (
isinstance(client, HTTPHandler)
or isinstance(client, AsyncHTTPHandler)
)
else None
),
timeout=timeout,
max_retries=max_retries,
logging_obj=litellm_logging_obj,
api_base=api_base,
api_key=api_key,
headers=extra_headers,
provider_config=provider_config, # type: ignore[arg-type]
)
elif provider_config is not None:
response = base_llm_http_handler.audio_transcriptions(
model=model,

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more