Merge litellm_internal_staging into DB-backed MCP OAuth branch

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-07-10 03:58:07 +00:00
commit b640701304
698 changed files with 47707 additions and 8036 deletions

View file

@ -1029,6 +1029,8 @@ jobs:
- *python312_image
working_directory: ~/project
resource_class: large
environment:
REQUEST_TIMEOUT: "180"
steps:
- checkout
@ -1058,7 +1060,8 @@ jobs:
-v -x \
--junitxml=test-results/junit.xml \
--durations=5 \
-n 8"
-n 8 \
--reruns 1 --only-rerun Timeout"
no_output_timeout: 15m
# Store test results
@ -1610,14 +1613,14 @@ jobs:
- run:
name: Run helm lint
command: |
helm lint ./deploy/charts/litellm-helm
helm lint ./helm/litellm-helm
# Run helm tests
- run:
name: Run helm tests
command: |
IMAGE_TAG=${CIRCLE_SHA1:-ci}
helm install litellm ./deploy/charts/litellm-helm -f ./deploy/charts/litellm-helm/ci/test-values.yaml \
helm install litellm ./helm/litellm-helm -f ./helm/litellm-helm/ci/test-values.yaml \
--set image.repository=litellm-ci \
--set image.tag=${IMAGE_TAG} \
--set image.pullPolicy=Never

View file

@ -13,7 +13,7 @@
- [ ] I have added meaningful tests
- [ ] My PR passes all CI/CD checks (e.g., lint, format, unit tests)
- [ ] My PR's scope is as isolated as possible; it only solves 1 specific problem
- [ ] I have requested a Greptile review by commenting `@greptileai` and received a **Confidence Score of at least 4/5** before requesting a maintainer review
- [ ] I have received a Greptile **Confidence Score of at least 4/5** before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment `@greptileai` to re-request a review after pushing changes)
## Delays in PR merge?
@ -24,6 +24,7 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac
<!-- Include screenshots, screen recordings, or command (e.g., curl) + output demonstrating that your changes work as expected
The proof must be completely e2e with no mocks, using, for example, actual LLM calls costing real $. `pytest` commands are not enough
For bug fixes: show reproduction before the fix and passing behavior after
Include the commit hash each proof was captured at, for both the before and the after runs
For new features: show the feature working end-to-end
For UI changes: include before/after screenshots -->

View file

@ -4,9 +4,11 @@ on:
push:
branches:
- main
- litellm_internal_staging
pull_request:
branches:
- main
- litellm_internal_staging
# Allow CodSpeed to trigger backtest performance analysis
# in order to generate initial data
workflow_dispatch:
@ -22,7 +24,7 @@ concurrency:
jobs:
benchmarks:
runs-on: ubuntu-24.04
timeout-minutes: 15
timeout-minutes: 60
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0

View file

@ -0,0 +1,61 @@
name: Create Daily OSS Branch
on:
schedule:
- cron: "0 16 * * 1-5" # 9am PT during daylight saving time, weekdays.
workflow_dispatch:
inputs:
date:
description: "Branch date in YYYY_MM_DD format. Defaults to today's UTC date."
required: false
type: string
permissions:
contents: write
jobs:
create-oss-branch:
if: github.repository == 'BerriAI/litellm'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false
- name: Create dated OSS branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REQUESTED_DATE: ${{ inputs.date }}
run: |
set -euo pipefail
if [ -n "${REQUESTED_DATE}" ]; then
if ! echo "${REQUESTED_DATE}" | grep -Eq '^[0-9]{4}_[0-9]{2}_[0-9]{2}$'; then
echo "::error::date must use YYYY_MM_DD format, got '${REQUESTED_DATE}'"
exit 1
fi
BRANCH_DATE="${REQUESTED_DATE}"
else
BRANCH_DATE="$(date -u +'%Y_%m_%d')"
fi
BRANCH_NAME="litellm_oss_daily_${BRANCH_DATE}"
echo "Creating branch: ${BRANCH_NAME}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main "${BRANCH_NAME}" || true
if git show-ref --verify --quiet "refs/remotes/origin/${BRANCH_NAME}"; then
echo "Branch ${BRANCH_NAME} already exists. Skipping creation."
exit 0
fi
git checkout -b "${BRANCH_NAME}" origin/main
git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${BRANCH_NAME}"
echo "Successfully created and pushed branch: ${BRANCH_NAME}"

View file

@ -39,5 +39,5 @@ jobs:
- name: Run unit tests
run: |
helm unittest -f 'tests/*.yaml' deploy/charts/litellm-helm
helm unittest -f 'tests/*.yaml' helm/litellm-helm
helm unittest -f 'tests/*.yaml' helm/litellm

View file

@ -0,0 +1,50 @@
name: OSS Daily Guardrails
on:
push:
branches:
- "litellm_oss_daily_20*"
pull_request:
branches:
- "litellm_oss_daily_20*"
- litellm_internal_staging
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
oss-safe-checks:
name: Run OSS daily safe checks
if: startsWith(github.ref_name, 'litellm_oss_daily_20') || startsWith(github.head_ref, 'litellm_oss_daily_20') || startsWith(github.base_ref, 'litellm_oss_daily_20')
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: "0.10.9"
- name: Run secret scan test
run: |
uv run --frozen --with 'pytest==9.0.2' pytest tests/litellm/test_no_hardcoded_secrets.py -v
- name: Run Ruff
run: |
uv sync --frozen
cd litellm
uv run --no-sync ruff check .

View file

@ -0,0 +1,113 @@
name: Terraform Provider
on:
push:
paths:
- "terraform/provider/**"
- ".github/workflows/test-terraform-provider.yml"
pull_request:
branches:
- main
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
paths:
- "terraform/provider/**"
- "litellm/proxy/**"
- ".github/workflows/test-terraform-provider.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
provider-checks:
name: gofmt, vet, build, test
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: terraform/provider
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: terraform/provider/go.mod
cache: true
cache-dependency-path: terraform/provider/go.sum
- name: gofmt
run: |
UNFORMATTED=$(gofmt -l .)
if [ -n "${UNFORMATTED}" ]; then
echo "::error::gofmt required for: ${UNFORMATTED}"
exit 1
fi
- name: go vet
run: go vet ./...
- name: Build
run: go build ./...
- name: Test
run: go test -timeout 120s ./...
endpoint-drift:
name: Provider endpoints vs proxy OpenAPI schema
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
version: "0.10.9"
- name: Cache uv dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cache/uv
.venv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
restore-keys: |
${{ runner.os }}-uv-
- name: Install dependencies
run: |
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router
- name: Generate Prisma client
env:
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
run: |
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Generate proxy OpenAPI schema
run: |
uv run --no-sync python terraform/provider/tools/dump_openapi.py "${RUNNER_TEMP}/openapi.json"
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: terraform/provider/go.mod
cache: true
cache-dependency-path: terraform/provider/go.sum
- name: Audit provider endpoints against the schema
working-directory: terraform/provider
run: go run ./tools/endpointaudit -provider-dir ./litellm -spec "${RUNNER_TEMP}/openapi.json"

View file

@ -16,6 +16,7 @@ jobs:
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
root_path: ["/api/v1", "/llmproxy"]
@ -108,8 +109,26 @@ jobs:
- name: Install UI deps and Chromium
working-directory: ui/litellm-dashboard
run: |
npm ci
npx playwright install --with-deps chromium
retry() {
local attempt=1
local max_attempts=4
until "$@"; do
if [ "$attempt" -ge "$max_attempts" ]; then
echo "Command failed after $attempt attempts: $*"
return 1
fi
echo "Attempt $attempt failed: $*. Retrying in $((attempt * 15))s..."
sleep $((attempt * 15))
attempt=$((attempt + 1))
done
}
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
retry npm ci
retry npx playwright install --with-deps chromium
- name: Run SERVER_ROOT_PATH redirect e2e
working-directory: ui/litellm-dashboard

5
.gitignore vendored
View file

@ -52,9 +52,8 @@ ui/litellm-dashboard/node_modules
ui/litellm-dashboard/next-env.d.ts
ui/litellm-dashboard/package.json
ui/litellm-dashboard/package-lock.json
deploy/charts/litellm/*.tgz
deploy/charts/litellm/charts/*
deploy/charts/*.tgz
helm/litellm-helm/*.tgz
helm/*.tgz
litellm/proxy/vertex_key.json
**/.vim/
**/node_modules

View file

@ -21,11 +21,11 @@ End-to-end tests belong in `tests/e2e/` and must follow the harness conventions
When creating PRs, don't set base to `main`. `litellm_internal_staging` serves that purpose
When writing a PR body, treat the comments and imperative instructions inside @.github/pull_request_template.md as rules to follow, not just layout
When writing a PR body, treat the comments and imperative instructions inside @.github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule
If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just leave the section blank
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR
If you ever make public-facing PR descriptions, comments, issues, commit messages, etc., always follow these guidelines to sound less AI-y:
- don't use emojis

View file

@ -1,10 +1,10 @@
# syntax=docker/dockerfile:1.7
# Base image for building
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
# Runtime image
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
# Pinned by digest like the other base images; bump explicitly on Node upgrades.
ARG UI_BUILD_IMAGE=node:20.18-alpine3.20@sha256:3488b10bf958af7125a176419d2d8a9937d895bf124012aae811651988d2ffe6

View file

@ -265,7 +265,7 @@ test-integration: install-test-deps
$(UV_RUN) pytest tests/ -k "not test_litellm"
test-unit-helm: install-helm-unittest
helm unittest -f 'tests/*.yaml' deploy/charts/litellm-helm
helm unittest -f 'tests/*.yaml' helm/litellm-helm
# LLM Translation testing targets
test-llm-translation: install-test-deps

View file

@ -1,5 +1,5 @@
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
FROM $UV_IMAGE AS uvbin

View file

@ -99,7 +99,7 @@
"limit": 0
},
"reportUnknownArgumentType": {
"limit": 45895
"limit": 45894
},
"reportUnknownLambdaType": {
"limit": 113

View file

@ -15,6 +15,16 @@ ignore:
flag_management:
default_rules:
carryforward: true
# Dead flags no CI job uploads anymore: their carried-forward sessions were
# measured against old revisions, and the stale line maps mark comment lines
# of since-edited files as missed, sinking patch coverage on unrelated PRs.
individual_flags:
- name: proxy-mgmt-behavior
carryforward: false
- name: security
carryforward: false
- name: proxy-db-schema-migration
carryforward: false
component_management:
individual_components:

View file

@ -1,15 +0,0 @@
{
"$schema": "https://schema.management.azure.com/schemas/0.1.2-preview/CreateUIDefinition.MultiVm.json#",
"handler": "Microsoft.Azure.CreateUIDef",
"version": "0.1.2-preview",
"parameters": {
"config": {
"isWizard": false,
"basics": { }
},
"basics": [ ],
"steps": [ ],
"outputs": { },
"resourceTypes": [ ]
}
}

View file

@ -1,63 +0,0 @@
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"imageName": {
"type": "string",
"defaultValue": "ghcr.io/berriai/litellm:main-latest"
},
"containerName": {
"type": "string",
"defaultValue": "litellm-container"
},
"dnsLabelName": {
"type": "string",
"defaultValue": "litellm"
},
"portNumber": {
"type": "int",
"defaultValue": 4000
}
},
"resources": [
{
"type": "Microsoft.ContainerInstance/containerGroups",
"apiVersion": "2021-03-01",
"name": "[parameters('containerName')]",
"location": "[resourceGroup().location]",
"properties": {
"containers": [
{
"name": "[parameters('containerName')]",
"properties": {
"image": "[parameters('imageName')]",
"resources": {
"requests": {
"cpu": 1,
"memoryInGB": 2
}
},
"ports": [
{
"port": "[parameters('portNumber')]"
}
]
}
}
],
"osType": "Linux",
"restartPolicy": "Always",
"ipAddress": {
"type": "Public",
"ports": [
{
"protocol": "tcp",
"port": "[parameters('portNumber')]"
}
],
"dnsNameLabel": "[parameters('dnsLabelName')]"
}
}
}
]
}

View file

@ -1,42 +0,0 @@
param imageName string = 'ghcr.io/berriai/litellm:main-latest'
param containerName string = 'litellm-container'
param dnsLabelName string = 'litellm'
param portNumber int = 4000
resource containerGroupName 'Microsoft.ContainerInstance/containerGroups@2021-03-01' = {
name: containerName
location: resourceGroup().location
properties: {
containers: [
{
name: containerName
properties: {
image: imageName
resources: {
requests: {
cpu: 1
memoryInGB: 2
}
}
ports: [
{
port: portNumber
}
]
}
}
]
osType: 'Linux'
restartPolicy: 'Always'
ipAddress: {
type: 'Public'
ports: [
{
protocol: 'tcp'
port: portNumber
}
]
dnsNameLabel: dnsLabelName
}
}
}

View file

@ -1,10 +1,10 @@
# syntax=docker/dockerfile:1.7
# Base image for building
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
# Runtime image
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
# Pinned by digest like the other base images; bump explicitly on Node upgrades.
ARG UI_BUILD_IMAGE=node:20.18-alpine3.20@sha256:3488b10bf958af7125a176419d2d8a9937d895bf124012aae811651988d2ffe6

View file

@ -1,8 +1,8 @@
# syntax=docker/dockerfile:1.7
# Base images
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
ARG PROXY_EXTRAS_SOURCE=published
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
# Pinned by digest like the other base images; bump explicitly on Node upgrades.

View file

@ -6,4 +6,4 @@ Code in this folder is licensed under a commercial license. Please review the [L
👉 **Using in an Enterprise / Need specific features ?** Meet with us [here](https://enterprise.litellm.ai/demo?month=2024-02)
See all Enterprise Features here 👉 [Docs](https://docs.litellm.ai/docs/proxy/enterprise)
See all Enterprise Features here 👉 [Docs](https://docs.litellm.ai/docs/enterprise)

View file

@ -919,9 +919,9 @@ class BaseEmailLogger(CustomLogger):
"""
Construct invitation link for the user
# http://localhost:4000/ui?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b
# http://localhost:4000/ui/onboarding?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b
"""
return f"{base_url}/ui?invitation_id={invitation_id}"
return f"{base_url}/ui/onboarding?invitation_id={invitation_id}"
async def send_email(
self,

View file

@ -17,6 +17,7 @@ if TYPE_CHECKING:
from litellm.proxy._types import LiteLLM_ManagedObjectTable
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.router import Router
from litellm.types.utils import LiteLLMBatch
CHECK_BATCH_COST_USER_AGENT = "LiteLLM Proxy/CheckBatchCost"
@ -277,13 +278,20 @@ class CheckBatchCost:
except Exception:
return None
async def check_batch_cost(self):
async def _track_completed_batch_cost(
self,
job: "LiteLLM_ManagedObjectTable",
response: "LiteLLMBatch",
model_id: str,
batch_id: str,
prom_logger: Optional["PrometheusLogger"],
) -> Optional[Tuple[Optional[str], Optional[str]]]:
"""
Check if the batch JOB has been tracked.
- get all status="validating" and file_purpose="batch" jobs
- check if batch is now complete
- if not, return False
- if so, return True
Fetch a completed batch's results, compute cost/usage, and emit the
aretrieve_batch spend log. Returns (model_name, llm_provider) on
success, None when the job can't be routed to a deployment. Raises on
results-fetch or cost-computation failures so the caller can leave the
job unprocessed and retry it on a later poll.
"""
from litellm.batches.batch_utils import (
_get_file_content_as_dictionary,
@ -296,6 +304,184 @@ class CheckBatchCost:
_is_base64_encoded_unified_file_id,
)
verbose_proxy_logger.info(
f"Batch ID: {batch_id} is complete, tracking cost and usage"
)
# aretrieve_batch is called with the raw provider batch ID, so response.id
# is the raw provider value (e.g. "batch_20260223-0518.234"). We need the
# unified base64 ID in the S3 log so downstream consumers can correlate it
# back to the batch they submitted via the proxy.
#
# CheckBatchCost builds its own LiteLLMLogging object (logging_obj below) and
# calls async_success_handler(result=response) directly. That handler calls
# _build_standard_logging_payload(response, ...) which reads response.id at
# that point — so setting response.id here is sufficient.
#
# The HTTP endpoint does this substitution via the managed files hook
# (async_post_call_success_hook). CheckBatchCost bypasses that hook entirely,
# so we do it explicitly here.
response.id = job.unified_object_id
# This background job runs as default_user_id, so going through the HTTP endpoint
# would trigger check_managed_file_id_access and get 403. Instead, extract the raw
# provider file ID and call afile_content directly with deployment credentials.
raw_output_file_id = response.output_file_id
decoded = _is_base64_encoded_unified_file_id(raw_output_file_id)
if decoded:
try:
raw_output_file_id = decoded.split("llm_output_file_id,")[1].split(";")[0]
except (IndexError, AttributeError):
pass
credentials = self.llm_router.get_deployment_credentials_with_provider(model_id) or {}
_file_content = await afile_content(
file_id=raw_output_file_id,
**credentials,
)
# Access content - handle both direct attribute and method call
if hasattr(_file_content, 'content'):
content_bytes = _file_content.content # type: ignore[union-attr]
elif hasattr(_file_content, 'read'):
content_bytes = await _file_content.read() # type: ignore[misc]
else:
content_bytes = _file_content # type: ignore[assignment]
file_content_as_dict = _get_file_content_as_dictionary(
content_bytes # type: ignore[arg-type]
)
# Record output file size
if prom_logger and content_bytes:
try:
prom_logger.record_managed_file_size(
size_bytes=len(content_bytes), # type: ignore
purpose="batch",
file_type="output",
model=model_id,
)
except Exception:
pass
deployment_info = self.llm_router.get_deployment(model_id=model_id)
if deployment_info is None:
verbose_proxy_logger.info(
f"Skipping job {job.unified_object_id} because it is not a valid deployment info"
)
self._record_error(prom_logger, "deployment_not_found")
return None
custom_llm_provider = deployment_info.litellm_params.custom_llm_provider
litellm_model_name = deployment_info.litellm_params.model
model_name, llm_provider, _, _ = get_llm_provider(
model=litellm_model_name,
custom_llm_provider=custom_llm_provider,
)
# CheckBatchCost bypasses async_post_call_success_hook, so convert raw
# output/error file IDs to managed base64 IDs before the DB write here.
managed_files_hook = self.proxy_logging_obj.get_proxy_hook("managed_files")
if managed_files_hook is not None:
from litellm.proxy._types import UserAPIKeyAuth
_minimal_auth = UserAPIKeyAuth(
user_id=job.created_by or "default-user-id",
team_id=getattr(job, "team_id", None),
)
for _file_attr in ["output_file_id", "error_file_id"]:
_raw_file_id = getattr(response, _file_attr, None)
if _raw_file_id and not _is_base64_encoded_unified_file_id(_raw_file_id):
try:
_unified_file_id = managed_files_hook.get_unified_output_file_id(
output_file_id=_raw_file_id,
model_id=model_id,
model_name=str(model_name) if model_name else deployment_info.model_name or None,
)
await managed_files_hook.store_unified_file_id(
file_id=_unified_file_id,
file_object=None,
litellm_parent_otel_span=None,
model_mappings={model_id: _raw_file_id},
user_api_key_dict=_minimal_auth,
)
setattr(response, _file_attr, _unified_file_id)
verbose_proxy_logger.info(
f"CheckBatchCost: converted {_file_attr} "
f"{_raw_file_id!r} -> managed ID for batch {batch_id}"
)
except Exception as _e:
verbose_proxy_logger.warning(
f"CheckBatchCost: failed to create managed file ID for "
f"{_file_attr}={_raw_file_id!r}: {_e}"
)
# Pass deployment model_info so custom batch pricing
# (input_cost_per_token_batches etc.) is used for cost calc
deployment_model_info = deployment_info.model_info.model_dump() if deployment_info.model_info else {}
batch_cost, batch_usage, batch_models = (
await calculate_batch_cost_and_usage(
file_content_dictionary=file_content_as_dict,
custom_llm_provider=llm_provider, # type: ignore
model_name=model_name,
model_info=deployment_model_info, # type: ignore[arg-type]
)
)
logging_obj = LiteLLMLogging(
model=batch_models[0],
messages=[{"role": "user", "content": "<retrieve_batch>"}],
stream=False,
call_type="aretrieve_batch",
start_time=datetime.now(),
litellm_call_id=str(uuid.uuid4()),
function_id=str(uuid.uuid4()),
)
creator_user_id = job.created_by
user_info = await self._get_user_info(batch_id, job.created_by)
logging_obj.update_environment_variables(
litellm_params={
# set the user-agent header so that S3 callback consumers can easily identify CheckBatchCost callbacks
"proxy_server_request": {
"headers": {
"user-agent": CHECK_BATCH_COST_USER_AGENT,
}
},
"metadata": {
"user_api_key_user_id": creator_user_id,
**user_info,
},
},
optional_params={},
)
await logging_obj.async_success_handler(
result=response,
batch_cost=batch_cost,
batch_usage=batch_usage,
batch_models=batch_models,
)
# Record batch duration (completed_at - created_at)
if prom_logger and response.completed_at and response.created_at:
duration_seconds = float(response.completed_at - response.created_at)
if duration_seconds >= 0:
prom_logger.record_managed_batch_duration(
duration_seconds=duration_seconds,
model=model_name,
api_provider=str(llm_provider) if llm_provider else None,
)
return model_name, str(llm_provider) if llm_provider else None
async def check_batch_cost(self):
"""
Check if the batch JOB has been tracked.
- get all status="validating" and file_purpose="batch" jobs
- check if batch is now complete
- if not, return False
- if so, return True
"""
try:
from litellm.integrations.prometheus import PrometheusLogger
prom_logger = PrometheusLogger.get_instance()
@ -381,177 +567,26 @@ class CheckBatchCost:
response.status == "completed"
and response.output_file_id is not None
):
verbose_proxy_logger.info(
f"Batch ID: {batch_id} is complete, tracking cost and usage"
)
# aretrieve_batch is called with the raw provider batch ID, so response.id
# is the raw provider value (e.g. "batch_20260223-0518.234"). We need the
# unified base64 ID in the S3 log so downstream consumers can correlate it
# back to the batch they submitted via the proxy.
#
# CheckBatchCost builds its own LiteLLMLogging object (logging_obj below) and
# calls async_success_handler(result=response) directly. That handler calls
# _build_standard_logging_payload(response, ...) which reads response.id at
# that point — so setting response.id here is sufficient.
#
# The HTTP endpoint does this substitution via the managed files hook
# (async_post_call_success_hook). CheckBatchCost bypasses that hook entirely,
# so we do it explicitly here.
response.id = job.unified_object_id
# This background job runs as default_user_id, so going through the HTTP endpoint
# would trigger check_managed_file_id_access and get 403. Instead, extract the raw
# provider file ID and call afile_content directly with deployment credentials.
raw_output_file_id = response.output_file_id
decoded = _is_base64_encoded_unified_file_id(raw_output_file_id)
if decoded:
try:
raw_output_file_id = decoded.split("llm_output_file_id,")[1].split(";")[0]
except (IndexError, AttributeError):
pass
credentials = self.llm_router.get_deployment_credentials_with_provider(model_id) or {}
_file_content = await afile_content(
file_id=raw_output_file_id,
**credentials,
)
# Access content - handle both direct attribute and method call
if hasattr(_file_content, 'content'):
content_bytes = _file_content.content # type: ignore[union-attr]
elif hasattr(_file_content, 'read'):
content_bytes = await _file_content.read() # type: ignore[misc]
else:
content_bytes = _file_content # type: ignore[assignment]
file_content_as_dict = _get_file_content_as_dictionary(
content_bytes # type: ignore[arg-type]
)
# Record output file size
if prom_logger and content_bytes:
try:
prom_logger.record_managed_file_size(
size_bytes=len(content_bytes), # type: ignore
purpose="batch",
file_type="output",
model=model_id,
)
except Exception:
pass
deployment_info = self.llm_router.get_deployment(model_id=model_id)
if deployment_info is None:
verbose_proxy_logger.info(
f"Skipping job {job.unified_object_id} because it is not a valid deployment info"
try:
tracked = await self._track_completed_batch_cost(
job=job,
response=response,
model_id=model_id,
batch_id=batch_id,
prom_logger=prom_logger,
)
if prom_logger:
prom_logger.record_check_batch_cost_error("deployment_not_found")
except Exception as tracking_err:
verbose_proxy_logger.error(
f"CheckBatchCost: failed to track cost for batch {batch_id} "
f"(job {job.id}); leaving it unprocessed so the next poll retries: {tracking_err}"
)
self._record_error(prom_logger, "cost_tracking_error")
continue
if tracked is None:
continue
custom_llm_provider = deployment_info.litellm_params.custom_llm_provider
litellm_model_name = deployment_info.litellm_params.model
model_name, llm_provider, _, _ = get_llm_provider(
model=litellm_model_name,
custom_llm_provider=custom_llm_provider,
)
# CheckBatchCost bypasses async_post_call_success_hook, so convert raw
# output/error file IDs to managed base64 IDs before the DB write here.
managed_files_hook = self.proxy_logging_obj.get_proxy_hook("managed_files")
if managed_files_hook is not None:
from litellm.proxy._types import UserAPIKeyAuth
_minimal_auth = UserAPIKeyAuth(
user_id=job.created_by or "default-user-id",
team_id=getattr(job, "team_id", None),
)
for _file_attr in ["output_file_id", "error_file_id"]:
_raw_file_id = getattr(response, _file_attr, None)
if _raw_file_id and not _is_base64_encoded_unified_file_id(_raw_file_id):
try:
_unified_file_id = managed_files_hook.get_unified_output_file_id(
output_file_id=_raw_file_id,
model_id=model_id,
model_name=str(model_name) if model_name else deployment_info.model_name or None,
)
await managed_files_hook.store_unified_file_id(
file_id=_unified_file_id,
file_object=None,
litellm_parent_otel_span=None,
model_mappings={model_id: _raw_file_id},
user_api_key_dict=_minimal_auth,
)
setattr(response, _file_attr, _unified_file_id)
verbose_proxy_logger.info(
f"CheckBatchCost: converted {_file_attr} "
f"{_raw_file_id!r} -> managed ID for batch {batch_id}"
)
except Exception as _e:
verbose_proxy_logger.warning(
f"CheckBatchCost: failed to create managed file ID for "
f"{_file_attr}={_raw_file_id!r}: {_e}"
)
# Pass deployment model_info so custom batch pricing
# (input_cost_per_token_batches etc.) is used for cost calc
deployment_model_info = deployment_info.model_info.model_dump() if deployment_info.model_info else {}
batch_cost, batch_usage, batch_models = (
await calculate_batch_cost_and_usage(
file_content_dictionary=file_content_as_dict,
custom_llm_provider=llm_provider, # type: ignore
model_name=model_name,
model_info=deployment_model_info, # type: ignore[arg-type]
)
)
logging_obj = LiteLLMLogging(
model=batch_models[0],
messages=[{"role": "user", "content": "<retrieve_batch>"}],
stream=False,
call_type="aretrieve_batch",
start_time=datetime.now(),
litellm_call_id=str(uuid.uuid4()),
function_id=str(uuid.uuid4()),
)
creator_user_id = job.created_by
user_info = await self._get_user_info(batch_id, job.created_by)
logging_obj.update_environment_variables(
litellm_params={
# set the user-agent header so that S3 callback consumers can easily identify CheckBatchCost callbacks
"proxy_server_request": {
"headers": {
"user-agent": CHECK_BATCH_COST_USER_AGENT,
}
},
"metadata": {
"user_api_key_user_id": creator_user_id,
**user_info,
},
},
optional_params={},
)
await logging_obj.async_success_handler(
result=response,
batch_cost=batch_cost,
batch_usage=batch_usage,
batch_models=batch_models,
)
# Record batch duration (completed_at - created_at)
if prom_logger and response.completed_at and response.created_at:
duration_seconds = float(response.completed_at - response.created_at)
if duration_seconds >= 0:
prom_logger.record_managed_batch_duration(
duration_seconds=duration_seconds,
model=model_name,
api_provider=str(llm_provider) if llm_provider else None,
)
# Track this job for the final metrics summary
processed_models.append((model_name, str(llm_provider) if llm_provider else None))
processed_models.append(tracked)
# mark the job as complete
try:

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-enterprise"
version = "0.1.47"
version = "0.1.49"
description = "Package for LiteLLM Enterprise features"
readme = "README.md"
requires-python = ">=3.9"
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
version = "0.1.47"
version = "0.1.49"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-enterprise==",

View file

@ -1,5 +1,5 @@
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
FROM $UV_IMAGE AS uvbin

View file

@ -25,17 +25,25 @@ DatabaseURLSettings.from_env().apply_to_env()
from litellm.proxy.proxy_server import app
from gateway.routes.allowlist import GATEWAY_EXACT_PATHS, GATEWAY_PATH_PREFIXES
from gateway.routes.allowlist import (
GATEWAY_EXACT_PATHS,
GATEWAY_MOUNT_PATHS,
GATEWAY_PATH_PREFIXES,
)
def _is_gateway_route(route) -> bool:
"""Keep the route on the gateway if its path is in the LLM data-plane surface."""
"""Keep the route on the gateway if its path is in the LLM data-plane surface.
Prometheus registers /metrics as a Mount (``app.mount("/metrics", make_asgi_app())``),
so Mounts are matched against GATEWAY_MOUNT_PATHS instead of being dropped with
the UI static mounts.
"""
path = getattr(route, "path", None)
if path is None:
return False
if isinstance(route, Mount):
# Gateway never serves the static UI or its asset bundles.
return False
return path in GATEWAY_MOUNT_PATHS
if path in GATEWAY_EXACT_PATHS:
return True
return any(path.startswith(prefix) for prefix in GATEWAY_PATH_PREFIXES)

View file

@ -106,7 +106,7 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
# Health & ops
"/health",
"/metrics",
"/watsonx"
"/watsonx",
)
GATEWAY_EXACT_PATHS: frozenset[str] = frozenset(
@ -120,3 +120,9 @@ GATEWAY_EXACT_PATHS: frozenset[str] = frozenset(
"/test",
}
)
GATEWAY_MOUNT_PATHS: frozenset[str] = frozenset(
{
"/metrics",
}
)

View file

@ -0,0 +1,8 @@
-- Timestamp sorts before some already-applied migrations; this is safe: the
-- runner is `prisma migrate deploy`, which applies every pending migration
-- regardless of name order (utils.py has an informational check for exactly
-- this), and IF NOT EXISTS keeps a re-apply idempotent.
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "token_exchange_endpoint" TEXT;
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "audience" TEXT;
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "subject_token_type" TEXT;

View file

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

View file

@ -329,6 +329,12 @@ model LiteLLM_MCPServerTable {
token_url String?
registration_url String?
oauth2_flow String?
token_exchange_endpoint String?
// Named for the RFC 8693 "audience" token-exchange request parameter (that flow only).
// RFC 8707 resource indicators are a separate concept, named "resource" in the v2 egress types.
audience String?
subject_token_type String?
token_exchange_profile String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(true)
delegate_auth_to_upstream Boolean @default(false)

View file

@ -379,6 +379,7 @@ budget_duration: Optional[str] = (
None # proxy only - resets budget after fixed duration. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d").
)
default_soft_budget: float = DEFAULT_SOFT_BUDGET # by default all litellm proxy keys have a soft budget of 50.0
budget_exceeded_throttle_percentage: Optional[float] = None
forward_traceparent_to_llm_provider: bool = False

View file

@ -11,7 +11,6 @@ from litellm._logging import verbose_logger
from litellm.a2a_protocol.cost_calculator import A2ACostCalculator
from litellm.a2a_protocol.utils import A2ARequestUtils
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.thread_pool_executor import executor
if TYPE_CHECKING:
from a2a.types import SendStreamingMessageRequest, SendStreamingMessageResponse
@ -128,22 +127,15 @@ class A2AStreamingIterator:
# Call success handlers - they will build standard_logging_object
asyncio.create_task(
self.logging_obj.async_success_handler(
result=result,
self.logging_obj.dispatch_success_handlers(
result,
start_time=self.start_time,
end_time=end_time,
cache_hit=None,
prefer_async_handlers=True,
)
)
executor.submit(
self.logging_obj.success_handler,
result=result,
cache_hit=None,
start_time=self.start_time,
end_time=end_time,
)
verbose_logger.info(
f"A2A streaming completed: prompt_tokens={prompt_tokens}, "
f"completion_tokens={completion_tokens}, total_tokens={total_tokens}, "

View file

@ -102,7 +102,7 @@
"computer-use-2025-01-24": "computer-use-2025-01-24",
"computer-use-2025-11-24": "computer-use-2025-11-24",
"context-1m-2025-08-07": "context-1m-2025-08-07",
"context-management-2025-06-27": null,
"context-management-2025-06-27": "context-management-2025-06-27",
"effort-2025-11-24": "effort-2025-11-24",
"fast-mode-2026-02-01": null,
"files-api-2025-04-14": null,

View file

@ -3,6 +3,7 @@ from typing import Any, Iterator, List, Literal, Optional, Tuple
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.llm_cost_calc.utils import _parse_prompt_tokens_details
from litellm.types.llms.openai import Batch
from litellm.types.utils import CallTypes, ModelInfo, Usage
from litellm.utils import token_counter
@ -34,7 +35,7 @@ async def calculate_batch_cost_and_usage(
custom_llm_provider=custom_llm_provider,
model_name=model_name,
)
batch_models = _get_batch_models_from_file_content(file_content_dictionary, model_name)
batch_models = _get_batch_models_from_file_content(file_content_dictionary, model_name, custom_llm_provider)
return batch_cost, batch_usage, batch_models
@ -70,7 +71,7 @@ async def _handle_completed_batch(
model_name=model_name,
)
batch_models = _get_batch_models_from_file_content(file_content_dictionary, model_name)
batch_models = _get_batch_models_from_file_content(file_content_dictionary, model_name, custom_llm_provider)
return batch_cost, batch_usage, batch_models
@ -78,6 +79,7 @@ async def _handle_completed_batch(
def _get_batch_models_from_file_content(
file_content_dictionary: List[dict],
model_name: Optional[str] = None,
custom_llm_provider: str = "openai",
) -> List[str]:
"""
Get the models from the file content
@ -86,8 +88,8 @@ def _get_batch_models_from_file_content(
return [model_name]
batch_models = []
for _item in file_content_dictionary:
if _batch_response_was_successful(_item):
_response_body = _get_response_from_batch_job_output_file(_item)
if _batch_response_was_successful(_item, custom_llm_provider):
_response_body = _get_response_from_batch_job_output_file(_item, custom_llm_provider)
_model = _response_body.get("model")
if _model:
batch_models.append(_model)
@ -373,10 +375,10 @@ def _get_batch_job_cost_from_file_content(
# parse the file content as json
verbose_logger.debug("file_content_dictionary=%s", json.dumps(file_content_dictionary, indent=4))
for _item in file_content_dictionary:
if _batch_response_was_successful(_item):
_response_body = _get_response_from_batch_job_output_file(_item)
if model_info is not None:
usage = _get_batch_job_usage_from_response_body(_response_body)
if _batch_response_was_successful(_item, custom_llm_provider):
_response_body = _get_response_from_batch_job_output_file(_item, custom_llm_provider)
if model_info is not None or custom_llm_provider == "anthropic":
usage = _get_batch_job_usage_from_response_body(_response_body, custom_llm_provider)
model = _response_body.get("model", "")
prompt_cost, completion_cost = batch_cost_calculator(
usage=usage,
@ -418,17 +420,31 @@ def _get_batch_job_total_usage_from_file_content(
total_tokens: int = 0
prompt_tokens: int = 0
completion_tokens: int = 0
cache_read_tokens: int = 0
cache_creation_tokens: int = 0
for _item in file_content_dictionary:
if _batch_response_was_successful(_item):
_response_body = _get_response_from_batch_job_output_file(_item)
usage: Usage = _get_batch_job_usage_from_response_body(_response_body)
if _batch_response_was_successful(_item, custom_llm_provider):
_response_body = _get_response_from_batch_job_output_file(_item, custom_llm_provider)
usage: Usage = _get_batch_job_usage_from_response_body(_response_body, custom_llm_provider)
total_tokens += usage.total_tokens
prompt_tokens += usage.prompt_tokens
completion_tokens += usage.completion_tokens
prompt_details = _parse_prompt_tokens_details(usage)
cache_read_tokens += prompt_details["cache_hit_tokens"]
cache_creation_tokens += prompt_details["cache_creation_tokens"]
cache_token_params = {
key: tokens
for key, tokens in (
("cache_read_input_tokens", cache_read_tokens),
("cache_creation_input_tokens", cache_creation_tokens),
)
if tokens > 0
}
return Usage(
total_tokens=total_tokens,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
**cache_token_params,
)
@ -465,27 +481,51 @@ def _count_prompt_or_input_tokens(model: str, value: Any) -> int:
return 0
def _get_batch_job_usage_from_response_body(response_body: dict) -> Usage:
def _get_batch_job_usage_from_response_body(response_body: dict, custom_llm_provider: str = "openai") -> Usage:
"""
Get the tokens of a batch job from the response body
"""
if custom_llm_provider == "anthropic":
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
return AnthropicConfig().calculate_usage(
usage_object=response_body.get("usage", None) or {},
reasoning_content=None,
)
_usage_dict = response_body.get("usage", None) or {}
usage: Usage = Usage(**_usage_dict)
return usage
def _get_response_from_batch_job_output_file(batch_job_output_file: dict) -> Any:
def _get_anthropic_result_from_batch_results_line(batch_results_line: dict) -> dict:
"""
Get the ``result`` object from a line of an Anthropic message batch results JSONL file.
Anthropic batch results lines look like:
``{"custom_id": ..., "result": {"type": "succeeded", "message": {..., "usage": {...}}}}``
"""
return batch_results_line.get("result", None) or {}
def _get_response_from_batch_job_output_file(batch_job_output_file: dict, custom_llm_provider: str = "openai") -> Any:
"""
Get the response from the batch job output file
"""
if custom_llm_provider == "anthropic":
return _get_anthropic_result_from_batch_results_line(batch_job_output_file).get("message", None) or {}
_response: dict = batch_job_output_file.get("response", None) or {}
_response_body = _response.get("body", None) or {}
return _response_body
def _batch_response_was_successful(batch_job_output_file: dict) -> bool:
def _batch_response_was_successful(batch_job_output_file: dict, custom_llm_provider: str = "openai") -> bool:
"""
Check if the batch job response status == 200
Check if the batch job response was successful
OpenAI-shaped output rows report ``response.status_code == 200``; Anthropic
message batch results lines report ``result.type == "succeeded"``.
"""
if custom_llm_provider == "anthropic":
return _get_anthropic_result_from_batch_results_line(batch_job_output_file).get("type") == "succeeded"
_response: dict = batch_job_output_file.get("response", None) or {}
return _response.get("status_code", None) == 200

View file

@ -279,7 +279,7 @@ class ValkeySemanticCache(RedisSemanticCache):
print_verbose("No prompt provided for semantic caching")
return
embedding = await self._get_async_embedding(prompt, **kwargs)
embedding = await self._get_async_embedding(prompt, metadata=kwargs.get("metadata"))
await self._ensure_index_async(len(embedding))
doc_key = self._doc_key(key)
@ -298,7 +298,7 @@ class ValkeySemanticCache(RedisSemanticCache):
kwargs.setdefault("metadata", {})["semantic-similarity"] = 0.0
return None
embedding = await self._get_async_embedding(prompt, **kwargs)
embedding = await self._get_async_embedding(prompt, metadata=kwargs.get("metadata"))
await self._ensure_index_async(len(embedding))
search_result = await self.async_client.ft(self.index_name).search(

View file

@ -715,6 +715,7 @@ openai_compatible_endpoints: List = [
"https://api.clarifai.com/v2/ext/openai/v1",
"https://api.libertai.io/v1",
"https://pinstripes.io/v1",
"https://api.meta.ai/v1",
]
@ -781,6 +782,7 @@ openai_compatible_providers: List = [
"ragflow",
"pinstripes", # Pinstripes - JSON-configured provider
"darkbloom",
"meta", # Meta Model API (Muse Spark) - JSON-configured provider
]
openai_text_completion_compatible_providers: List = [ # providers that support `/v1/completions`
"together_ai",
@ -1504,6 +1506,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [
"public_model_groups_links",
"cost_discount_config",
"cost_margin_config",
"budget_exceeded_throttle_percentage",
]
SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"]
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60))

View file

@ -2155,17 +2155,23 @@ def batch_cost_calculator(
if input_cost_per_token_batches:
total_prompt_cost = usage.prompt_tokens * input_cost_per_token_batches
elif input_cost_per_token:
details = _parse_prompt_tokens_details(usage)
cache_read_tokens = details["cache_hit_tokens"]
cache_creation_tokens = details["cache_creation_tokens"]
# Subtract cached tokens from prompt_tokens before calculating cost
# Fixes issue where cached tokens are being charged again
base_input_tokens = get_billable_input_tokens(usage) - cache_creation_tokens
total_prompt_cost = (
get_billable_input_tokens(usage) * (input_cost_per_token) / 2
base_input_tokens * (input_cost_per_token) / 2
) # batch cost is usually half of the regular token cost
# Add cache read cost if applicable
details = _parse_prompt_tokens_details(usage)
cache_read_tokens = details["cache_hit_tokens"]
cache_read_cost_key = _get_service_tier_cost_key("cache_read_input_token_cost", None)
total_prompt_cost += calculate_cost_component(model_info, cache_read_cost_key, cache_read_tokens) / 2
cache_creation_cost = model_info.get("cache_creation_input_token_cost") or input_cost_per_token
total_prompt_cost += cache_creation_tokens * cache_creation_cost / 2
if output_cost_per_token_batches:
total_completion_cost = usage.completion_tokens * output_cost_per_token_batches
elif output_cost_per_token:

View file

@ -1180,20 +1180,6 @@ class ModifyResponseException(Exception):
super().__init__(message)
class GuardrailInterventionNormalStringError(
Exception
): # custom exception to raise when a guardrail intervenes, but we want to return a normal string to the user
def __init__(self, message: str):
self.message = message
super().__init__(self.message)
def __str__(self):
return self.message
def __repr__(self):
return self.__str__()
class SensitiveDataRouteException(Exception):
"""
Exception raised when a guardrail detects sensitive data and wants to reroute the request.

View file

@ -757,6 +757,12 @@ class CustomGuardrail(CustomLogger):
# raw provider JSON so redaction is not duplicated upstream).
clean_guardrail_response = redact_nested_match_and_regex_keys(clean_guardrail_response)
from litellm.litellm_core_utils.sensitive_data_masker import (
mask_credentials_in_payload,
)
clean_guardrail_response = mask_credentials_in_payload(clean_guardrail_response)
slg = StandardLoggingGuardrailInformation(
guardrail_name=self.guardrail_name,
guardrail_provider=guardrail_provider,

View file

@ -52,6 +52,7 @@ from litellm.integrations.otel.model.semconv import (
GenAIProvider,
JsonRpc,
LiteLLM,
LiteLLMError,
MCPMethod,
Metric,
Network,
@ -87,6 +88,7 @@ __all__ = [
"HTTP",
"JsonRpc",
"LiteLLM",
"LiteLLMError",
"MCP",
"MCPMethod",
"Metric",

View file

@ -16,9 +16,10 @@ from litellm.integrations.otel.model.payloads import (
MCPListToolsSpanData,
MCPToolCallSpanData,
ServiceSpanData,
SpanError,
)
from litellm.integrations.otel.plumbing.providers import to_otel_span_kind
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent, LiteLLMError
from litellm.integrations.otel.model.spans import (
SPAN_REGISTRY,
SpanRole,
@ -49,6 +50,27 @@ _NAME_BUILDERS: dict[SpanRole, Callable[..., str]] = {
_DEDUP_CACHE_MAX = 10_000
def _stamp_otel_error_attributes(span: Span, error_type: str, resolved_message: str) -> None:
"""Stamp the OTel-semconv error attributes (``error.type`` + ``error.message``).
``error_type`` and ``resolved_message`` are ``finish_span``'s already-computed
fallback chains, so the pair on the status, event, and attributes stays in
lockstep."""
span.set_attribute(Error.TYPE, error_type)
span.set_attribute(Error.MESSAGE, resolved_message)
def _stamp_litellm_error_attributes(span: Span, error: SpanError) -> None:
"""Stamp litellm-specific error detail attributes. Emitted only when the
corresponding field is populated so guardrail-shape errors carrying only a
message aren't polluted with empty detail keys."""
if error.code:
span.set_attribute(LiteLLMError.CODE, error.code)
if error.stack_trace:
span.set_attribute(LiteLLMError.STACK_TRACE, error.stack_trace)
if error.llm_provider:
span.set_attribute(LiteLLMError.LLM_PROVIDER, error.llm_provider)
class SpanEmitter:
def __init__(
self,
@ -190,12 +212,13 @@ class SpanEmitter:
if error and (error.error_type or error.message):
error_type = error.error_type or "error"
message = error.message or error.error_type or "error"
span.set_attribute(Error.TYPE, error_type)
_stamp_otel_error_attributes(span, error_type, message)
_stamp_litellm_error_attributes(span, error)
span.set_status(Status(StatusCode.ERROR, message))
# Carry the full message on the standard ``exception`` event so backends
# map it as full text under ``exception.message``. Setting it as a bare
# string attribute instead lets backends like Elasticsearch dynamic-map
# it to a ``keyword`` capped at 1024 chars, truncating the message.
# Also emit the semconv ``exception`` event so backends that
# dynamic-map unknown string span attrs to ``keyword`` (e.g.
# Elasticsearch with a 1024-char ``ignore_above``) still see the
# full untruncated message on the recognized event field.
span.add_event(
ExceptionEvent.NAME,
{ExceptionEvent.TYPE: error_type, ExceptionEvent.MESSAGE: message},

View file

@ -368,7 +368,11 @@ class OpenTelemetryV2(CustomLogger):
# it (named provisionally) so it isn't leaked as an open span.
carrier.span.end(end_time=to_ns(end_time))
return None
data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=self.config.capture_span_content)
data = LLMCallSpanData.from_standard_logging_payload(
payload,
capture_content=self.config.capture_span_content,
time_to_first_chunk_seconds=call.time_to_first_chunk_seconds,
)
end_time_ns = to_ns(end_time)
if carrier.span is not None:
# Born at the boundary: stamp attributes from the typed payload, set

View file

@ -55,6 +55,7 @@ class GenAIMapper:
GenAI.RESPONSE_MODEL: lambda d: d.response_model,
GenAI.RESPONSE_ID: lambda d: d.response_id,
GenAI.RESPONSE_FINISH_REASONS: lambda d: list(d.finish_reasons) if d.finish_reasons else None,
GenAI.RESPONSE_TIME_TO_FIRST_CHUNK: lambda d: d.time_to_first_chunk_seconds,
GenAI.USAGE_INPUT_TOKENS: lambda d: d.usage.input_tokens,
GenAI.USAGE_OUTPUT_TOKENS: lambda d: d.usage.output_tokens,
Error.TYPE: lambda d: d.error.error_type if d.error else None,

View file

@ -41,7 +41,7 @@ from typing import TYPE_CHECKING, Any, Mapping, cast
from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL
from litellm.integrations.otel.model.semconv import resolve_operation
from litellm.integrations.otel.model.utils import as_str
from litellm.integrations.otel.model.utils import as_str, to_seconds
if TYPE_CHECKING:
from litellm.types.utils import StandardLoggingPayload
@ -201,6 +201,7 @@ class LLMCallEvent:
# span is renamed from the typed payload at close (``finish_span``); this only
# needs to be reasonable for a span that never gets closed (a leak).
provisional_span_name: str
time_to_first_chunk_seconds: float | None
@classmethod
def from_dict(cls, kwargs: Mapping[str, Any]) -> "LLMCallEvent":
@ -214,9 +215,25 @@ class LLMCallEvent:
dynamic_params=kwargs.get("standard_callback_dynamic_params"),
is_no_upstream_call=bool(kwargs.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)),
provisional_span_name=f"{operation.value} {model}".strip(),
time_to_first_chunk_seconds=time_to_first_chunk_seconds(kwargs),
)
def time_to_first_chunk_seconds(kwargs: Mapping[str, Any]) -> float | None:
"""Seconds from the upstream request being issued (``api_call_start_time``)
to the first streamed chunk (``completion_start_time``); ``None`` for
non-streaming calls, where ``completion_start_time`` is backfilled with the
end time and would not measure first-chunk latency."""
optional_params = cast(Mapping[str, Any], kwargs.get("optional_params") or {})
if not optional_params.get("stream"):
return None
api_call_start = to_seconds(kwargs.get("api_call_start_time"))
completion_start = to_seconds(kwargs.get("completion_start_time"))
if api_call_start is None or completion_start is None:
return None
return completion_start - api_call_start
def _call_id(payload: "StandardLoggingPayload | None", kwargs: Mapping[str, Any]) -> str | None:
"""The call id from the payload (when closed) or the bare kwargs (at pre_call)."""
if payload is not None:

View file

@ -141,6 +141,9 @@ class LLMCost:
class SpanError:
error_type: str | None = None
message: str | None = None
code: str | None = None
stack_trace: str | None = None
llm_provider: str | None = None
@dataclass(frozen=True)
@ -305,10 +308,14 @@ class LLMCallSpanData:
messages_in: tuple[Mapping[str, object], ...] = ()
choices_out: tuple[Mapping[str, object], ...] = ()
system_fingerprint: str | None = None
time_to_first_chunk_seconds: float | None = None
@classmethod
def from_standard_logging_payload(
cls, payload: "StandardLoggingPayload", capture_content: bool = False
cls,
payload: "StandardLoggingPayload",
capture_content: bool = False,
time_to_first_chunk_seconds: float | None = None,
) -> "LLMCallSpanData":
params = cast(Mapping[str, object], payload.get("model_parameters") or {})
# The single parse of the request's metadata — the request-vs-provider
@ -349,6 +356,7 @@ class LLMCallSpanData:
messages_in=_dicts(payload.get("messages")) if capture_content else (),
choices_out=choices_out if capture_content else (),
system_fingerprint=as_str(response.get("system_fingerprint")),
time_to_first_chunk_seconds=time_to_first_chunk_seconds,
)
@ -566,6 +574,9 @@ def _parse_error(payload: "StandardLoggingPayload") -> SpanError | None:
return SpanError(
error_type=as_str(info.get("error_class")) or as_str(info.get("error_code")),
message=as_str(info.get("error_message")) or as_str(payload.get("error_str")),
code=as_str(info.get("error_code")),
stack_trace=as_str(info.get("traceback")),
llm_provider=as_str(info.get("llm_provider")),
)

View file

@ -69,6 +69,7 @@ class GenAI:
RESPONSE_ID: Final = "gen_ai.response.id"
RESPONSE_MODEL: Final = "gen_ai.response.model"
RESPONSE_FINISH_REASONS: Final = "gen_ai.response.finish_reasons"
RESPONSE_TIME_TO_FIRST_CHUNK: Final = "gen_ai.response.time_to_first_chunk"
# usage
USAGE_INPUT_TOKENS: Final = "gen_ai.usage.input_tokens"
USAGE_OUTPUT_TOKENS: Final = "gen_ai.usage.output_tokens"
@ -143,7 +144,24 @@ class Client:
class Error:
"""OTel-defined error attribute keys, from the semconv ``error.*`` registry.
``MESSAGE`` is marked *Deprecated* upstream in favor of domain-specific
error message keys plus ``exception.message`` on the exception event, but
litellm still stamps it."""
TYPE: Final = "error.type"
MESSAGE: Final = "error.message"
class LiteLLMError:
"""Detail keys for the mapped provider exception of a failed LLM call.
OTel semconv does not define these, so they live under the ``litellm.*``
vendor namespace rather than squatting on the semconv-owned ``error.*``
namespace."""
CODE: Final = "litellm.provider.error.code"
STACK_TRACE: Final = "litellm.provider.error.stack_trace"
LLM_PROVIDER: Final = "litellm.provider.error.llm_provider"
class ExceptionEvent:

View file

@ -21,6 +21,7 @@ from litellm.integrations.opentelemetry import (
_build_metric_attribute_filter,
_resolve_metric_attribute_filter,
)
from litellm.integrations.otel.model.metadata import time_to_first_chunk_seconds
from litellm.integrations.otel.model.semconv import Metric, resolve_operation
from litellm.integrations.otel.model.utils import to_seconds
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@ -181,13 +182,10 @@ class GenAIMetricRecorder:
self._metrics.token_usage.record(usage.get("completion_tokens", 0), attributes=out_attrs)
def _record_time_to_first_token(self, kwargs: Mapping[str, Any], common_attrs: dict) -> None:
if not kwargs.get("optional_params", {}).get("stream", False):
time_to_first_chunk = time_to_first_chunk_seconds(kwargs)
if time_to_first_chunk is None:
return
api_call_start = to_seconds(kwargs.get("api_call_start_time"))
completion_start = to_seconds(kwargs.get("completion_start_time"))
if api_call_start is None or completion_start is None:
return
self._metrics.time_to_first_token.record(completion_start - api_call_start, attributes=common_attrs)
self._metrics.time_to_first_token.record(time_to_first_chunk, attributes=common_attrs)
def _record_time_per_output_token(
self,

View file

@ -3619,6 +3619,7 @@ class PrometheusLogger(CustomLogger):
hashed_token=user_api_key_dict.token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_cache_only=True,
)
if key_object:
user_api_key_dict.budget_reset_at = key_object.budget_reset_at

View file

@ -174,22 +174,15 @@ class InteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator):
logging_response = copy.deepcopy(self.completed_response)
asyncio.create_task(
self.logging_obj.async_success_handler(
result=logging_response,
self.logging_obj.dispatch_success_handlers(
logging_response,
start_time=self.start_time,
end_time=datetime.now(),
cache_hit=None,
prefer_async_handlers=True,
)
)
executor.submit(
self.logging_obj.success_handler,
result=logging_response,
cache_hit=None,
start_time=self.start_time,
end_time=datetime.now(),
)
class SyncInteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator):
"""

View file

@ -123,6 +123,34 @@ def process_audio_file(audio_file: FileTypes) -> ProcessedAudioFile:
return ProcessedAudioFile(file_content=file_content, filename=filename, content_type=content_type)
BARE_ISO_639_1_TO_BCP47 = {
"en": "en-US",
"es": "es-ES",
"de": "de-DE",
"fr": "fr-FR",
"it": "it-IT",
"pt": "pt-BR",
"ja": "ja-JP",
"ko": "ko-KR",
"zh": "zh-CN",
"ru": "ru-RU",
"hi": "hi-IN",
"ar": "ar-SA",
}
def normalize_transcription_language_to_bcp47(language: str) -> str:
"""
OpenAI's transcription `language` param accepts bare ISO-639-1 codes like
``en``; speech APIs such as Google Speech-to-Text and NVIDIA Riva require
BCP-47 like ``en-US``. Map the most common bare codes and pass through
anything already region-qualified (or unknown, for a clear provider error).
"""
if "-" in language:
return language
return BARE_ISO_639_1_TO_BCP47.get(language.lower(), language)
def get_audio_file_name(file_obj: FileTypes) -> str:
"""
Safely get the name of a file-like object or return its string representation.

View file

@ -1944,7 +1944,7 @@ def _map_azure_exception(
response=getattr(original_exception, "response", None),
body=getattr(original_exception, "body", None),
)
elif "invalid_request_error" in error_str:
elif "invalid_request_error" in error_str and getattr(original_exception, "status_code", None) in (None, 400):
raise BadRequestError(
message=f"AzureException BadRequestError - {message}",
llm_provider="azure",
@ -1986,6 +1986,14 @@ def _map_azure_exception(
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
)
elif original_exception.status_code == 404:
raise NotFoundError(
message=f"AzureException NotFoundError - {message}",
llm_provider="azure",
model=model,
litellm_debug_info=extra_information,
response=getattr(original_exception, "response", None),
)
elif original_exception.status_code == 408:
raise Timeout(
message=f"AzureException Timeout - {message}",
@ -2173,7 +2181,7 @@ def exception_type( # type: ignore
litellm_response_headers = _get_response_headers(original_exception=original_exception)
try:
error_str = redact_string(str(original_exception)) if _ENABLE_SECRET_REDACTION else str(original_exception)
if model:
if model or custom_llm_provider:
if hasattr(original_exception, "message"):
error_str = (
redact_string(str(original_exception.message))

View file

@ -76,6 +76,7 @@ def get_litellm_params(
proxy_server_request=None,
acompletion=None,
aembedding=None,
allm_passthrough_route=None,
preset_cache_key=None,
no_log=None,
input_cost_per_second=None,
@ -118,6 +119,7 @@ def get_litellm_params(
# Build base dict with explicit parameters (always included)
litellm_params = {
"acompletion": acompletion,
"allm_passthrough_route": allm_passthrough_route,
"api_key": api_key,
"force_timeout": force_timeout,
"logger_fn": logger_fn,

View file

@ -346,6 +346,9 @@ def get_llm_provider(
elif endpoint == "https://pinstripes.io/v1":
custom_llm_provider = "pinstripes"
dynamic_api_key = get_secret_str("PINSTRIPES_API_KEY")
elif endpoint == "https://api.meta.ai/v1":
custom_llm_provider = "meta"
dynamic_api_key = get_secret_str("META_API_KEY")
if api_base is not None and not isinstance(api_base, str):
raise Exception("api base needs to be a string. api_base={}".format(api_base))

View file

@ -95,6 +95,17 @@ class HealthCheckHelpers:
"""
import litellm
logging_obj = filtered_model_params.get("litellm_logging_obj")
if logging_obj is not None:
api_base = filtered_model_params.get("api_base")
logging_obj.update_from_kwargs(
kwargs=filtered_model_params,
model=filtered_model_params.get("model"),
user=None,
optional_params={},
litellm_params={"api_base": api_base} if api_base else None,
)
if custom_llm_provider in LIST_BATCHES_SUPPORTED_PROVIDERS:
return await litellm.alist_batches(**filtered_model_params)
else:
@ -188,6 +199,7 @@ class HealthCheckHelpers:
api_base=model_params.get("api_base", None),
api_key=model_params.get("api_key", None),
api_version=model_params.get("api_version", None),
model_params=model_params,
),
"batch": lambda: HealthCheckHelpers._batch_health_check(
custom_llm_provider=custom_llm_provider,

View file

@ -1530,6 +1530,7 @@ class Logging(LiteLLMLoggingBaseClass):
and litellm_params.get(CallTypes.aembedding.value, False) is not True
and litellm_params.get(CallTypes.aimage_generation.value, False) is not True
and litellm_params.get(CallTypes.atranscription.value, False) is not True
and litellm_params.get(CallTypes.allm_passthrough_route.value, False) is not True
)
def _is_assembled_stream_success(self, result=None) -> bool:

View file

@ -3626,6 +3626,7 @@ class BedrockImageProcessor:
def _convert_to_bedrock_tool_call_invoke(
tool_calls: list,
model: Optional[str] = None,
) -> List[BedrockContentBlock]:
"""
OpenAI tool invokes:
@ -3701,7 +3702,13 @@ def _convert_to_bedrock_tool_call_invoke(
# cache_control applies to the whole original
# tool call; attach after the last split block.
if tool.get("cache_control", None) is not None:
_parts_list.append(BedrockContentBlock(cachePoint=CachePointBlock(type="default")))
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
{"cache_control": tool["cache_control"]},
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
_parts_list.append(_cache_point_block)
continue
# Fallback: no objects extracted — use empty dict.
arguments_dict = {}
@ -3712,8 +3719,13 @@ def _convert_to_bedrock_tool_call_invoke(
# Check for cache_control and add a separate cachePoint block
if tool.get("cache_control", None) is not None:
cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default"))
_parts_list.append(cache_point_block)
cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
{"cache_control": tool["cache_control"]},
block_type="content_block",
model=model,
)
if cache_point_block is not None:
_parts_list.append(cache_point_block)
return _parts_list
except Exception as e:
raise Exception(
@ -4377,6 +4389,7 @@ class BedrockConverseMessagesProcessor:
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block=cast(OpenAIMessageContentListBlock, element),
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
_parts.append(_cache_point_block)
@ -4384,7 +4397,7 @@ class BedrockConverseMessagesProcessor:
elif message_block["content"] and isinstance(message_block["content"], str):
_part = BedrockContentBlock(text=messages[msg_i]["content"])
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block, block_type="content_block"
message_block, block_type="content_block", model=model
)
user_content.append(_part)
if _cache_point_block is not None:
@ -4416,22 +4429,27 @@ class BedrockConverseMessagesProcessor:
tool_content.append(tool_call_result)
# Check if we need to add a separate cachePoint block
has_cache_control = False
tool_msg_cache_control = None
# Check for message-level cache_control
if current_message.get("cache_control", None) is not None:
has_cache_control = True
tool_msg_cache_control = current_message["cache_control"]
# Check for content-level cache_control in list content
elif isinstance(current_message.get("content"), list):
for content_element in current_message["content"]:
if isinstance(content_element, dict) and content_element.get("cache_control", None) is not None:
has_cache_control = True
tool_msg_cache_control = content_element["cache_control"]
break
# Add a separate cachePoint block if cache_control is present
if has_cache_control:
cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default"))
tool_content.append(cache_point_block)
if tool_msg_cache_control is not None:
cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
{"cache_control": tool_msg_cache_control},
block_type="content_block",
model=model,
)
if cache_point_block is not None:
tool_content.append(cache_point_block)
msg_i += 1
# Deduplicate toolResult blocks with the same toolUseId
@ -4509,6 +4527,7 @@ class BedrockConverseMessagesProcessor:
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block=cast(OpenAIMessageContentListBlock, element),
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
assistants_parts.append(_cache_point_block)
@ -4520,14 +4539,14 @@ class BedrockConverseMessagesProcessor:
# If content is empty/whitespace, skip it (don't add a placeholder)
# Add cache point block for assistant string content
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
assistant_message_block, block_type="content_block"
assistant_message_block, block_type="content_block", model=model
)
if _cache_point_block is not None:
assistant_content.append(_cache_point_block)
_tool_calls = assistant_message_block.get("tool_calls", [])
if _tool_calls:
assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls))
assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls, model=model))
msg_i += 1
@ -4745,6 +4764,7 @@ def _bedrock_converse_messages_pt(
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block=cast(OpenAIMessageContentListBlock, element),
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
_parts.append(_cache_point_block)
@ -4752,7 +4772,7 @@ def _bedrock_converse_messages_pt(
elif message_block["content"] and isinstance(message_block["content"], str):
_part = BedrockContentBlock(text=messages[msg_i]["content"])
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block, block_type="content_block"
message_block, block_type="content_block", model=model
)
user_content.append(_part)
if _cache_point_block is not None:
@ -4786,22 +4806,27 @@ def _bedrock_converse_messages_pt(
tool_content.append(tool_call_result)
# Check if we need to add a separate cachePoint block
has_cache_control = False
tool_msg_cache_control = None
# Check for message-level cache_control
if current_message.get("cache_control", None) is not None:
has_cache_control = True
tool_msg_cache_control = current_message["cache_control"]
# Check for content-level cache_control in list content
elif isinstance(current_message.get("content"), list):
for content_element in current_message["content"]:
if isinstance(content_element, dict) and content_element.get("cache_control", None) is not None:
has_cache_control = True
tool_msg_cache_control = content_element["cache_control"]
break
# Add a separate cachePoint block if cache_control is present
if has_cache_control:
cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default"))
tool_content.append(cache_point_block)
if tool_msg_cache_control is not None:
cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
{"cache_control": tool_msg_cache_control},
block_type="content_block",
model=model,
)
if cache_point_block is not None:
tool_content.append(cache_point_block)
msg_i += 1
# Deduplicate toolResult blocks with the same toolUseId
@ -4882,6 +4907,7 @@ def _bedrock_converse_messages_pt(
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
message_block=cast(OpenAIMessageContentListBlock, element),
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
assistants_parts.append(_cache_point_block)
@ -4892,13 +4918,13 @@ def _bedrock_converse_messages_pt(
assistant_content.append(BedrockContentBlock(text=_assistant_content))
# Add cache point block for assistant string content
_cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block(
assistant_message_block, block_type="content_block"
assistant_message_block, block_type="content_block", model=model
)
if _cache_point_block is not None:
assistant_content.append(_cache_point_block)
_tool_calls = assistant_message_block.get("tool_calls", [])
if _tool_calls:
assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls))
assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls, model=model))
msg_i += 1

View file

@ -1,5 +1,4 @@
import asyncio
import concurrent.futures
import json
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union, cast
@ -25,9 +24,6 @@ if TYPE_CHECKING:
else:
CLIENT_CONNECTION_CLASS = Any
# Create a thread pool with a maximum of 10 threads
executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
class RealtimeEventNormalizer(Protocol):
def should_drop(self, event: object) -> bool: ...
@ -315,13 +311,12 @@ class RealTimeStreaming:
if self.session_tools or self.tool_calls:
self.logging_obj.model_call_details["realtime_tools"] = self.session_tools
self.logging_obj.model_call_details["realtime_tool_calls"] = self.tool_calls
## ASYNC LOGGING
# Route through the bounded logging worker (per-coroutine timeout +
# concurrency cap) instead of a bare create_task, so a slow callback
# can't leave suspended tasks pinning each call's response in memory.
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(self.logging_obj.async_success_handler(self.messages))
## SYNC LOGGING
executor.submit(self.logging_obj.success_handler(self.messages))
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(
self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True)
)
async def _send_to_backend(self, message: str) -> bool:
"""Send a message to the backend WebSocket.

View file

@ -1,6 +1,8 @@
from collections.abc import Mapping
from typing import Any, Dict, List, Optional, Set
from pydantic import BaseModel
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER
@ -153,6 +155,39 @@ def mask_sensitive_structure(data: object) -> object:
return _error_masker.mask(data)
def mask_credentials_in_payload(data: object) -> object:
"""Return a copy of ``data`` where string values under sensitive-named keys
are masked but every other value (``None``, ``int``, ``float``, ``bool``,
``bytes``, ``datetime``, tuples, sets, typed objects) is preserved by
identity, and dicts/lists are rebuilt structurally.
Use this for logging payloads that carry response data through to
SpendLogs / OTel / Langfuse, where :meth:`SensitiveDataMasker.mask`'s
config-dump semantics (``None`` -> ``"None"``, tuples stringified,
objects flattened via ``__dict__``) would silently distort the record.
Sensitive-key detection is delegated to the shared
:class:`SensitiveDataMasker` so pattern updates stay in one place.
"""
return _walk_payload(data, key_is_sensitive=False, depth=0)
def _walk_payload(node: object, key_is_sensitive: bool, depth: int) -> object:
if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER:
return node
if isinstance(node, Mapping):
return {k: _walk_payload(v, _default_masker.is_sensitive_key(k), depth + 1) for k, v in node.items()}
if isinstance(node, list):
return [_walk_payload(item, key_is_sensitive, depth + 1) for item in node]
if isinstance(node, tuple):
return tuple(_walk_payload(item, key_is_sensitive, depth + 1) for item in node)
if isinstance(node, BaseModel):
return _walk_payload(node.model_dump(), key_is_sensitive, depth)
if key_is_sensitive and isinstance(node, str) and node:
return _default_masker._mask_value(node)
return node
def mask_sensitive_keys(data: Dict[str, Any], sensitive_fields: Set[str]) -> Dict[str, Any]:
"""Return a new dict with values masked for keys listed in ``sensitive_fields``.

View file

@ -39,6 +39,7 @@ from ..common_utils import AnthropicError, AnthropicModelInfo
ANTHROPIC_FILES_API_BASE = "https://api.anthropic.com"
ANTHROPIC_FILES_BETA_HEADER = "files-api-2025-04-14"
ANTHROPIC_MESSAGE_BATCH_ID_PREFIX = "msgbatch_"
class AnthropicFilesConfig(BaseFilesConfig):
@ -258,6 +259,8 @@ class AnthropicFilesConfig(BaseFilesConfig):
file_id = file_content_request.get("file_id")
api_base = AnthropicModelInfo.get_api_base(litellm_params.get("api_base")) or ANTHROPIC_FILES_API_BASE
encoded_file_id = encode_url_path_segment(file_id, field_name="file_id")
if file_id.startswith(ANTHROPIC_MESSAGE_BATCH_ID_PREFIX):
return f"{api_base.rstrip('/')}/v1/messages/batches/{encoded_file_id}/results", {}
return f"{api_base.rstrip('/')}/v1/files/{encoded_file_id}/content", {}
def transform_file_content_response(

View file

@ -205,7 +205,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
#########################################################
########## DELETE RESPONSE API TRANSFORMATION ##############
#########################################################
def _construct_url_for_response_id_in_path(self, api_base: str, response_id: str) -> str:
def _construct_url_for_response_id_in_path(self, api_base: str, response_id: str, path_suffix: str = "") -> str:
"""
Constructs a URL for the API request with the response_id in the path.
"""
@ -218,14 +218,14 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
# Remove trailing slash if present to avoid double slashes
path = parsed_url.path.rstrip("/")
encoded_response_id = encode_url_path_segment(response_id, field_name="response_id")
new_path = f"{path}/{encoded_response_id}"
new_path = f"{path}/{encoded_response_id}{path_suffix}"
# Reconstruct the URL with all original components but with the modified path
constructed_url = urlunparse(
(
parsed_url.scheme, # http, https
parsed_url.netloc, # domain name, port
new_path, # path with response_id added
new_path,
parsed_url.params, # parameters
parsed_url.query, # query string
parsed_url.fragment, # fragment
@ -288,7 +288,9 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
limit: int = 20,
order: Literal["asc", "desc"] = "desc",
) -> Tuple[str, Dict]:
url = self._construct_url_for_response_id_in_path(api_base=api_base, response_id=response_id) + "/input_items"
url = self._construct_url_for_response_id_in_path(
api_base=api_base, response_id=response_id, path_suffix="/input_items"
)
params: Dict[str, Any] = {}
if after is not None:
params["after"] = after
@ -322,27 +324,8 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
This function handles URLs with query parameters by inserting the response_id
at the correct location (before any query parameters).
"""
from urllib.parse import urlparse, urlunparse
# Parse the URL to separate its components
parsed_url = urlparse(api_base)
# Insert the response_id and /cancel at the end of the path component
# Remove trailing slash if present to avoid double slashes
path = parsed_url.path.rstrip("/")
encoded_response_id = encode_url_path_segment(response_id, field_name="response_id")
new_path = f"{path}/{encoded_response_id}/cancel"
# Reconstruct the URL with all original components but with the modified path
cancel_url = urlunparse(
(
parsed_url.scheme, # http, https
parsed_url.netloc, # domain name, port
new_path, # path with response_id and /cancel added
parsed_url.params, # parameters
parsed_url.query, # query string
parsed_url.fragment, # fragment
)
cancel_url = self._construct_url_for_response_id_in_path(
api_base=api_base, response_id=response_id, path_suffix="/cancel"
)
data: Dict = {}

View file

@ -13,6 +13,17 @@ if TYPE_CHECKING:
from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig
def is_azure_document_intelligence_model(model: str) -> bool:
"""Whether an azure_ai OCR model routes to Azure Document Intelligence.
Azure AI exposes two OCR services on the same provider; the sub-route in the
model name (`azure_ai/doc-intelligence/<model>`) selects Document Intelligence
over Mistral OCR. This is the single source of truth for that routing decision.
"""
lowered = model.lower()
return "doc-intelligence" in lowered or "documentintelligence" in lowered
def get_azure_ai_ocr_config(model: str) -> Optional["BaseOCRConfig"]:
"""
Determine which Azure AI OCR configuration to use based on the model name.
@ -41,7 +52,7 @@ def get_azure_ai_ocr_config(model: str) -> Optional["BaseOCRConfig"]:
from litellm.llms.azure_ai.ocr.transformation import AzureAIOCRConfig
# Check for Azure Document Intelligence models
if "doc-intelligence" in model or "documentintelligence" in model:
if is_azure_document_intelligence_model(model):
verbose_logger.debug(f"Routing {model} to Azure Document Intelligence OCR config")
return AzureDocumentIntelligenceOCRConfig()

View file

@ -50,6 +50,8 @@ _STS_REGION_FROM_ENDPOINT_PATTERN = re.compile(
r"(?:^|\.)sts(?:-fips)?\.([a-z0-9-]+)\.(?:amazonaws\.com(?:\.cn)?|vpce\.amazonaws\.com)"
)
SIGV4_COMPUTED_HEADERS = frozenset({"authorization", "x-amz-date", "x-amz-security-token", "date"})
class Boto3CredentialsInfo(BaseModel):
credentials: Credentials
@ -1400,11 +1402,13 @@ class BaseAWSLLM:
# Add back all original headers (including forwarded ones) after signature calculation
for header_name, header_value in headers.items():
if header_value is not None:
if header_value is not None and header_name.lower() not in SIGV4_COMPUTED_HEADERS:
request.headers[header_name] = header_value
if (
extra_headers is not None and "Authorization" in extra_headers
extra_headers is not None
and "Authorization" in extra_headers
and not extra_headers["Authorization"].startswith("AWS4-HMAC-SHA256")
): # prevent sigv4 from overwriting the auth header
request.headers["Authorization"] = extra_headers["Authorization"]
prepped = request.prepare()
@ -1527,9 +1531,15 @@ class BaseAWSLLM:
# Add back original headers after signing. Only headers in SignedHeaders
# are integrity-protected; forwarded headers (x-forwarded-*) must remain unsigned.
for header_name, header_value in headers.items():
if header_value is not None:
if header_value is not None and header_name.lower() not in SIGV4_COMPUTED_HEADERS:
request_headers_dict[header_name] = header_value
if headers is not None and "Authorization" in headers: # prevent sigv4 from overwriting the auth header
request_headers_dict["Authorization"] = headers["Authorization"]
incoming_authorization = next(
(value for name, value in headers.items() if name.lower() == "authorization" and value is not None),
None,
)
if incoming_authorization is not None and not incoming_authorization.startswith(
"AWS4-HMAC-SHA256"
): # prevent sigv4 from overwriting the auth header
request_headers_dict["Authorization"] = incoming_authorization
return request_headers_dict, request.body

View file

@ -7,13 +7,14 @@ The bedrock-mantle endpoint uses the Anthropic Messages API format but is served
at a different endpoint (bedrock-mantle.{region}.api.aws) with AWS SigV4 auth.
"""
from typing import TYPE_CHECKING, Any, List, Optional
from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional
from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeConfig,
)
from litellm.llms.bedrock.common_utils import build_mantle_messages_url
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@ -91,10 +92,14 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig):
litellm_params=litellm_params,
headers=headers,
)
# The parent strips "model" from the body (Invoke API puts it in URL).
# The mantle endpoint (Messages API) requires "model" in the body.
request["model"] = model_id
return request
# The parent strips "model" and "stream" from the body (Invoke API puts
# the model in the URL and streams via a dedicated endpoint). The mantle
# endpoint (Messages API) requires both in the body.
return self._restore_mantle_body_fields(
request=request,
model_id=model_id,
optional_params=optional_params,
)
async def async_transform_request(
self,
@ -114,5 +119,31 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig):
headers=headers,
)
await self._async_convert_document_url_sources_to_base64(request)
request["model"] = model_id
return request
return self._restore_mantle_body_fields(
request=request,
model_id=model_id,
optional_params=optional_params,
)
@staticmethod
def _restore_mantle_body_fields(request: dict, model_id: str, optional_params: dict) -> dict:
stream_fields: dict = {"stream": True} if optional_params.get("stream") is True else {}
return {**request, "model": model_id, **stream_fields}
@property
def has_custom_stream_wrapper(self) -> bool:
return False
def get_model_response_iterator(
self,
streaming_response: Iterator[str] | AsyncIterator[str] | ModelResponse,
sync_stream: bool,
json_mode: Optional[bool] = False,
) -> Any:
from litellm.llms.anthropic.chat.handler import ModelResponseIterator
return ModelResponseIterator(
streaming_response=streaming_response,
sync_stream=sync_stream,
json_mode=json_mode,
)

View file

@ -489,24 +489,43 @@ class AmazonAnthropicClaudeMessagesConfig(
if self._supports_tool_search_on_bedrock(model):
beta_set.add("tool-search-tool-2025-10-19")
# Bedrock-InvokeModel-supported ``context_management.edits`` types and the
# ``anthropic-beta`` header that each one requires. ``clear_thinking_20251015``
# is intentionally absent — it is LiteLLM-internal, consumed via
# ``_ensure_thinking_for_clear_thinking_context_management``, and forwarding
# the raw edit trips Bedrock's
# ``"context_management: Extra inputs are not permitted"`` 400.
#
# Bedrock InvokeModel DOES support ``clear_tool_uses_20250919`` under the
# ``context-management-2025-06-27`` beta. AWS docs:
# https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-tool-use.md
_BEDROCK_INVOKE_SUPPORTED_CONTEXT_MANAGEMENT_EDITS: Dict[str, str] = {
"compact_20260112": ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_01_12.value,
"clear_tool_uses_20250919": ANTHROPIC_BETA_HEADER_VALUES.CONTEXT_MANAGEMENT_2025_06_27.value,
}
@staticmethod
def _filter_context_management_for_bedrock_invoke(
anthropic_messages_request: Dict,
beta_set: set,
) -> None:
"""
Bedrock InvokeModel accepts ``context_management`` only when it carries
``compact_20260112`` edits paired with the ``compact-2026-01-12``
anthropic-beta header. Other edit types (notably ``clear_thinking_20251015``,
which Claude Code sends on every request) are LiteLLM-internal and would
cause Bedrock to 400 with ``"context_management: Extra inputs are not
permitted"``.
Filter ``context_management.edits`` to the subset that Bedrock InvokeModel
accepts and add the matching ``anthropic-beta`` header for each surviving
edit type.
Filter the edits list to the supported subset, add the beta header when
compact edits remain, and drop ``context_management`` entirely when no
supported edits are left so the safety-net allowlist can pass it through.
- ``compact_20260112`` -> ``compact-2026-01-12``
- ``clear_tool_uses_20250919`` -> ``context-management-2025-06-27``
Ref: https://github.com/BerriAI/litellm/issues/27532
Other edit types (notably ``clear_thinking_20251015``, which Claude Code
sends on every request) are LiteLLM-internal: thinking is injected
separately via ``_ensure_thinking_for_clear_thinking_context_management``,
and forwarding the raw edit would trip Bedrock's
``"context_management: Extra inputs are not permitted"`` 400.
Refs:
* https://github.com/BerriAI/litellm/issues/27532
* https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-tool-use.md
"""
cm = anthropic_messages_request.get("context_management")
if not isinstance(cm, dict):
@ -516,15 +535,17 @@ class AmazonAnthropicClaudeMessagesConfig(
anthropic_messages_request.pop("context_management", None)
return
compact_edits = [e for e in edits if isinstance(e, dict) and e.get("type") == "compact_20260112"]
if compact_edits:
beta_set.add(ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_01_12.value)
anthropic_messages_request["context_management"] = {
**cm,
"edits": compact_edits,
}
else:
supported = AmazonAnthropicClaudeMessagesConfig._BEDROCK_INVOKE_SUPPORTED_CONTEXT_MANAGEMENT_EDITS
retained_edits = [e for e in edits if isinstance(e, dict) and e.get("type") in supported]
if not retained_edits:
anthropic_messages_request.pop("context_management", None)
return
beta_set.update(supported[e["type"]] for e in retained_edits)
anthropic_messages_request["context_management"] = {
**cm,
"edits": retained_edits,
}
def _get_bedrock_invoke_anthropic_beta_headers(
self,

View file

@ -6,8 +6,13 @@ AmazonAnthropicClaudeMessagesConfig. Overrides only the URL and model-prefix
stripping that are specific to the bedrock-mantle endpoint.
"""
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Tuple
import httpx
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.bedrock.common_utils import build_mantle_messages_url
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeMessagesConfig,
@ -89,8 +94,26 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig):
headers=headers,
)
# Parent (AmazonAnthropicClaudeMessagesConfig) removes "model" from the
# body (Bedrock Invoke puts model in the URL). The mantle endpoint
# (Messages API) requires "model" in the request body.
request["model"] = model_id
return request
# Parent (AmazonAnthropicClaudeMessagesConfig) removes "model" and
# "stream" from the body (Bedrock Invoke puts the model in the URL and
# streams via a dedicated endpoint). The mantle endpoint (Messages API)
# requires both in the request body.
stream_fields: dict[str, bool] = (
{"stream": True} if anthropic_messages_optional_request_params.get("stream") is True else {}
)
return {**request, "model": model_id, **stream_fields}
def get_async_streaming_response_iterator(
self,
model: str,
httpx_response: httpx.Response,
request_body: dict,
litellm_logging_obj: LiteLLMLoggingObj,
) -> AsyncIterator:
return AnthropicMessagesConfig.get_async_streaming_response_iterator(
self,
model=model,
httpx_response=httpx_response,
request_body=request_body,
litellm_logging_obj=litellm_logging_obj,
)

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