mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-27 01:22:18 +00:00
Merge remote-tracking branch 'origin/main' into litellm_azure_ai_mai_image_2_5_pro
Some checks failed
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
Some checks failed
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
66078f4834
767 changed files with 25219 additions and 7709 deletions
|
|
@ -1650,6 +1650,7 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4001:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \
|
||||
-e LITELLM_MASTER_KEY="sk-1234" \
|
||||
--name schema-seed \
|
||||
|
|
@ -1670,6 +1671,7 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \
|
||||
-e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \
|
||||
-e DISABLE_SCHEMA_UPDATE="True" \
|
||||
|
|
@ -1744,7 +1746,9 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
|
||||
-e LITELLM_MASTER_KEY="sk-1234" \
|
||||
-e USE_PRISMA_MIGRATE=True \
|
||||
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
|
||||
-e AZURE_API_KEY=$AZURE_API_KEY \
|
||||
|
|
@ -1839,7 +1843,9 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
|
||||
-e LITELLM_MASTER_KEY="sk-1234" \
|
||||
-e AZURE_API_KEY=$AZURE_API_KEY \
|
||||
-e AZURE_API_BASE=$AZURE_API_BASE \
|
||||
-e AZURE_API_VERSION="2024-05-01-preview" \
|
||||
|
|
@ -1927,6 +1933,7 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
|
||||
-e REDIS_HOST=$REDIS_HOST \
|
||||
-e REDIS_PASSWORD=$REDIS_PASSWORD \
|
||||
|
|
@ -1987,6 +1994,7 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
|
||||
-e REDIS_HOST=$REDIS_HOST \
|
||||
-e REDIS_PASSWORD=$REDIS_PASSWORD \
|
||||
|
|
@ -2064,6 +2072,7 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
|
||||
-e REDIS_HOST=host.docker.internal \
|
||||
-e REDIS_PORT=6379 \
|
||||
|
|
@ -2146,6 +2155,7 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
|
||||
-e REDIS_HOST=$REDIS_HOST \
|
||||
-e REDIS_PASSWORD=$REDIS_PASSWORD \
|
||||
|
|
@ -2168,6 +2178,7 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4001:4001 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
|
||||
-e REDIS_HOST=$REDIS_HOST \
|
||||
-e REDIS_PASSWORD=$REDIS_PASSWORD \
|
||||
|
|
@ -2245,6 +2256,7 @@ jobs:
|
|||
docker run -d \
|
||||
--restart on-failure \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
|
||||
-e STORE_MODEL_IN_DB="True" \
|
||||
-e LITELLM_MASTER_KEY="sk-1234" \
|
||||
|
|
@ -2319,6 +2331,7 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
|
||||
-e REDIS_HOST=$REDIS_HOST \
|
||||
-e REDIS_PASSWORD=$REDIS_PASSWORD \
|
||||
|
|
@ -2401,6 +2414,7 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
|
||||
-e LITELLM_MASTER_KEY="sk-1234" \
|
||||
-e OPENAI_API_KEY=$OPENAI_API_KEY \
|
||||
|
|
@ -2492,6 +2506,7 @@ jobs:
|
|||
command: |
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-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 \
|
||||
|
|
@ -2673,6 +2688,7 @@ jobs:
|
|||
name: Start LiteLLM proxy
|
||||
environment:
|
||||
LITELLM_MASTER_KEY: "sk-1234"
|
||||
LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY: "true"
|
||||
MOCK_LLM_URL: "http://127.0.0.1:8090/v1"
|
||||
DISABLE_SCHEMA_UPDATE: "true"
|
||||
SERVER_ROOT_PATH: ""
|
||||
|
|
@ -2816,6 +2832,7 @@ jobs:
|
|||
name: Start LiteLLM proxy under a server root path
|
||||
environment:
|
||||
LITELLM_MASTER_KEY: "sk-1234"
|
||||
LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY: "true"
|
||||
MOCK_LLM_URL: "http://127.0.0.1:8090/v1"
|
||||
DISABLE_SCHEMA_UPDATE: "true"
|
||||
# Output flows to this step's own log, so a boot crash is visible here
|
||||
|
|
@ -2901,6 +2918,7 @@ jobs:
|
|||
command: |
|
||||
docker run --name my-app \
|
||||
-p 4000:4000 \
|
||||
-e LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true \
|
||||
-e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \
|
||||
-e DATABASE_URL="postgresql://wrong:wrong@wrong:5432/wrong" \
|
||||
myapp:latest \
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ NOVITA_API_KEY = ""
|
|||
INFINITY_API_KEY = ""
|
||||
|
||||
# Development Configs
|
||||
LITELLM_MASTER_KEY = "sk-1234"
|
||||
# Generate one with: echo "LITELLM_MASTER_KEY=sk-$(openssl rand -hex 32)"
|
||||
LITELLM_MASTER_KEY = ""
|
||||
DATABASE_URL = "postgresql://llmproxy:dbpassword9090@db:5432/litellm"
|
||||
STORE_MODEL_IN_DB = "True"
|
||||
|
|
|
|||
20
.github/e2e-stack/assert_tests_ran.py
vendored
20
.github/e2e-stack/assert_tests_ran.py
vendored
|
|
@ -1,3 +1,5 @@
|
|||
import os
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
|
@ -15,6 +17,7 @@ def main() -> int:
|
|||
_ = sys.stdout.write("::error::could not read the test execution report\n")
|
||||
return 1
|
||||
cases: Final = tuple(report.iter("testcase"))
|
||||
expected_count: Final = os.environ.get("E2E_REQUIRED_TEST_COUNT")
|
||||
passed: Final = frozenset(
|
||||
case.get("file") for case in cases if all(case.find(tag) is None for tag in ("skipped", "failure", "error"))
|
||||
)
|
||||
|
|
@ -35,9 +38,22 @@ def main() -> int:
|
|||
skipped: Final = sum(case.get("file") == path and case.find("skipped") is not None for case in cases)
|
||||
_ = sys.stdout.write(f"{path}: {collected} collected, {skipped} skipped\n")
|
||||
for case in cases:
|
||||
if case.get("file") != path or all(case.find(tag) is None for tag in ("failure", "error")):
|
||||
if case.get("file") != path or all(case.find(tag) is None for tag in ("failure", "error", "skipped")):
|
||||
continue
|
||||
_ = sys.stdout.write(f" failed: {case.get('classname', '')}::{case.get('name', '')}\n")
|
||||
outcome = "skipped" if case.find("skipped") is not None else "failed"
|
||||
_ = sys.stdout.write(f" {outcome}: {case.get('classname', '')}::{case.get('name', '')}\n")
|
||||
for prop in case.findall("./properties/property"):
|
||||
name = prop.get("name", "")
|
||||
value = prop.get("value", "")
|
||||
if name in ("oauth_failure_phase", "oauth_exception_type", "oauth_frame") and re.fullmatch(
|
||||
r"[A-Za-z0-9_.:<>-]{1,240}", value
|
||||
):
|
||||
_ = sys.stdout.write(f" {name}: {value}\n")
|
||||
if expected_count is not None and (
|
||||
len(cases) != int(expected_count) or any(case.find("skipped") is not None for case in cases)
|
||||
):
|
||||
_ = sys.stdout.write("::error::required test count was not met or a required case was skipped\n")
|
||||
return 1
|
||||
if (
|
||||
selected
|
||||
and not missing
|
||||
|
|
|
|||
1
.github/e2e-stack/select_tests.py
vendored
1
.github/e2e-stack/select_tests.py
vendored
|
|
@ -5,6 +5,7 @@ from typing import Final
|
|||
SELECTABLE: Final = re.compile(r"^tests/e2e/([A-Za-z0-9_.-]+/)*test_[A-Za-z0-9_.-]+\.py$")
|
||||
UNSUPPORTED: Final = re.compile(
|
||||
r"^tests/e2e/(ui|claude_code|load)/"
|
||||
r"|^tests/e2e/mcp/test_mcp_oauth_happy_path_e2e\.py$"
|
||||
r"|^tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e\.py$"
|
||||
r"|^tests/e2e/batches/test_managed_files_enforcement_e2e\.py$"
|
||||
r"|^tests/e2e/guardrails/test_presidio_masking_e2e\.py$"
|
||||
|
|
|
|||
180
.github/workflows/test-mcp-oauth-e2e.yml
vendored
Normal file
180
.github/workflows/test-mcp-oauth-e2e.yml
vendored
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
name: MCP OAuth happy path
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-mcp-oauth-e2e.yml'
|
||||
- '.github/e2e-stack/**'
|
||||
- 'tests/e2e/*.py'
|
||||
- 'tests/e2e/pytest.ini'
|
||||
- 'tests/e2e/idp_realm.json'
|
||||
- 'tests/e2e/mcp/**'
|
||||
- 'litellm/experimental_mcp_client/**'
|
||||
- 'litellm/proxy/_experimental/mcp_server/**'
|
||||
- 'litellm/proxy/auth/**'
|
||||
- 'litellm/proxy/management_endpoints/mcp_management_endpoints.py'
|
||||
- 'litellm/proxy/_types.py'
|
||||
- 'litellm/types/mcp_server/mcp_server_manager.py'
|
||||
- 'litellm/proxy/management_endpoints/*sso*.py'
|
||||
- 'litellm/proxy/management_endpoints/sso/**'
|
||||
- 'litellm/proxy/common_utils/encrypt_decrypt_utils.py'
|
||||
- 'litellm/proxy/proxy_server.py'
|
||||
- 'litellm/proxy/schema.prisma'
|
||||
- 'ui/litellm-dashboard/src/app/connect/**'
|
||||
- 'ui/litellm-dashboard/src/app/mcp/oauth/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: mcp-oauth-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
oauth:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
environment: e2e-changed
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16.6
|
||||
env:
|
||||
POSTGRES_USER: litellm
|
||||
POSTGRES_PASSWORD: dbpassword9090
|
||||
POSTGRES_DB: litellm
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U litellm"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
DATABASE_HOST: 127.0.0.1
|
||||
DATABASE_PORT: '5432'
|
||||
DATABASE_USER: litellm
|
||||
DATABASE_PASSWORD: dbpassword9090
|
||||
DATABASE_NAME: litellm
|
||||
DATABASE_URL: postgresql://litellm:dbpassword9090@127.0.0.1:5432/litellm
|
||||
E2E_KEYCLOAK_URL: http://127.0.0.1:8081
|
||||
E2E_KEYCLOAK_ADMIN_USER: admin
|
||||
E2E_KEYCLOAK_ADMIN_PASSWORD: e2e-ephemeral-idp-not-a-secret
|
||||
E2E_FIXTURE_MODE: live
|
||||
E2E_PROVIDER_CACHE: '0'
|
||||
E2E_MCP_OAUTH_LIVE: '1'
|
||||
E2E_REQUIRED_TEST_COUNT: '4'
|
||||
steps:
|
||||
- name: Checkout the tested source
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Require and materialize the upstream login
|
||||
env:
|
||||
STORAGE_STATE: ${{ secrets.E2E_LINEAR_STORAGE_STATE_B64 }}
|
||||
run: |
|
||||
umask 077
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
encoded = os.environ.get("STORAGE_STATE", "")
|
||||
if not encoded:
|
||||
raise SystemExit("E2E_LINEAR_STORAGE_STATE_B64 is required; capture and provision a test-account login")
|
||||
state = json.loads(base64.b64decode(encoded, validate=True))
|
||||
if not isinstance(state, dict) or not state.get("cookies"):
|
||||
raise SystemExit("The captured login must contain browser cookies")
|
||||
directory = Path(os.environ["RUNNER_TEMP"]) / "mcp-oauth-private"
|
||||
directory.mkdir(mode=0o700)
|
||||
path = directory / "linear-state.json"
|
||||
path.write_text(json.dumps(state))
|
||||
with open(os.environ["GITHUB_ENV"], "a") as output:
|
||||
output.write(f"E2E_LINEAR_STORAGE_STATE={path}\n")
|
||||
for name in ("LITELLM_MASTER_KEY", "LITELLM_SALT_KEY"):
|
||||
value = "sk-e2e-" + secrets.token_hex(24)
|
||||
print(f"::add-mask::{value}")
|
||||
output.write(f"{name}={value}\n")
|
||||
PY
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: '0.10.9'
|
||||
- uses: ./.github/actions/cache-cargo-build
|
||||
- name: Install the frozen E2E environment
|
||||
run: |
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --extra proxy --extra proxy-runtime --extra extra_proxy --group ci --group proxy-dev --group e2e-dev
|
||||
uv run --no-sync python scripts/prisma_generate_if_needed.py
|
||||
uv run --no-sync playwright install --with-deps chromium
|
||||
|
||||
- name: Configure license access
|
||||
id: aws
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
role-to-assume: ${{ vars.E2E_AWS_ROLE_TO_ASSUME }}
|
||||
aws-region: us-east-1
|
||||
role-session-name: mcp-oauth-${{ github.run_id }}
|
||||
role-duration-seconds: 900
|
||||
output-env-credentials: false
|
||||
output-credentials: true
|
||||
- name: Load the E2E license
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ steps.aws.outputs.aws-access-key-id }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ steps.aws.outputs.aws-secret-access-key }}
|
||||
AWS_SESSION_TOKEN: ${{ steps.aws.outputs.aws-session-token }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
license="$(aws secretsmanager get-secret-value --secret-id litellm-e2e-changed-license --query SecretString --output text)"
|
||||
test -n "${license}"
|
||||
echo "::add-mask::${license}"
|
||||
echo "LITELLM_LICENSE=${license}" >> "${GITHUB_ENV}"
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: ui/litellm-dashboard/.nvmrc
|
||||
- name: Build the gateway consent UI at the tested commit
|
||||
run: |
|
||||
cd ui/litellm-dashboard
|
||||
../../scripts/with_dashboard_node.sh npm ci
|
||||
../../scripts/with_dashboard_node.sh npm run build
|
||||
mkdir -p ../../litellm/proxy/_experimental/out
|
||||
cp -r out/. ../../litellm/proxy/_experimental/out/
|
||||
find ../../litellm/proxy/_experimental/out -name '*.html' ! -name index.html | while read -r page; do
|
||||
mkdir -p "${page%.html}"
|
||||
mv "${page}" "${page%.html}/index.html"
|
||||
done
|
||||
|
||||
- name: Prepare the isolated database and IdP
|
||||
run: |
|
||||
umask 077
|
||||
bash .github/e2e-stack/start-idp.sh
|
||||
uv run --no-sync python migrations/run.py > "${RUNNER_TEMP}/mcp-oauth-private/migrations.log" 2>&1
|
||||
|
||||
- name: Run every required OAuth variant without retries
|
||||
run: |
|
||||
umask 077
|
||||
uv run --no-sync pytest -c tests/e2e/pytest.ini tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py \
|
||||
--rootdir=. --reruns 0 --tb=short -o junit_family=xunit1 \
|
||||
--junitxml="${RUNNER_TEMP}/mcp-oauth-private/results.xml" \
|
||||
> "${RUNNER_TEMP}/mcp-oauth-private/pytest.log" 2>&1
|
||||
- name: Report JUnit results and reject skipped or missing cases
|
||||
if: always()
|
||||
run: |
|
||||
uv run --no-sync python .github/e2e-stack/assert_tests_ran.py \
|
||||
"${RUNNER_TEMP}/mcp-oauth-private/results.xml" tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py
|
||||
- name: Remove private login and logs
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f e2e-keycloak >/dev/null 2>&1 || true
|
||||
rm -rf "${RUNNER_TEMP}/mcp-oauth-private"
|
||||
|
|
@ -268,10 +268,13 @@ If you want to build the Docker image yourself:
|
|||
# Build using the non-root Dockerfile
|
||||
docker build -f docker/Dockerfile.non_root -t litellm_dev .
|
||||
|
||||
# Generate a master key. Requests send it as the bearer token
|
||||
export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 32)"
|
||||
|
||||
# Run with your config
|
||||
docker run \
|
||||
-v $(pwd)/proxy_config.yaml:/app/config.yaml \
|
||||
-e LITELLM_MASTER_KEY="sk-1234" \
|
||||
-e LITELLM_MASTER_KEY \
|
||||
-p 4000:4000 \
|
||||
litellm_dev \
|
||||
--config /app/config.yaml --detailed_debug
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ from a2a.utils.constants import TransportProtocol
|
|||
from uuid import uuid4
|
||||
|
||||
base_url = "http://localhost:4000/a2a/my-agent" # LiteLLM proxy + agent name
|
||||
headers = {"Authorization": "Bearer sk-1234"} # LiteLLM Virtual Key
|
||||
headers = {"Authorization": "Bearer <your-master-key>"} # LiteLLM master key or a virtual key
|
||||
|
||||
async with httpx.AsyncClient(headers=headers, timeout=60.0) as http_client:
|
||||
resolver = A2ACardResolver(httpx_client=http_client, base_url=base_url)
|
||||
|
|
@ -233,7 +233,7 @@ async with stdio_client(server_params) as (read, write):
|
|||
|
||||
```bash
|
||||
curl -X POST 'http://0.0.0.0:4000/v1/chat/completions' \
|
||||
-H 'Authorization: Bearer sk-1234' \
|
||||
-H 'Authorization: Bearer <your-master-key>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "gpt-4o",
|
||||
|
|
@ -255,7 +255,7 @@ curl -X POST 'http://0.0.0.0:4000/v1/chat/completions' \
|
|||
"LiteLLM": {
|
||||
"url": "http://localhost:4000/mcp/",
|
||||
"headers": {
|
||||
"x-litellm-api-key": "Bearer sk-1234"
|
||||
"x-litellm-api-key": "Bearer <your-master-key>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
# YOU MUST CHANGE THESE BEFORE GOING INTO PRODUCTION
|
||||
############
|
||||
|
||||
LITELLM_MASTER_KEY="sk-1234"
|
||||
# Generate one with: echo "LITELLM_MASTER_KEY=sk-$(openssl rand -hex 32)"
|
||||
LITELLM_MASTER_KEY=""
|
||||
|
||||
############
|
||||
# Database - You can change these to any PostgreSQL database that has logical replication enabled.
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ async def _get_email_settings(prisma_client) -> Dict[str, bool]:
|
|||
|
||||
async def _save_email_settings(prisma_client, settings: Dict[str, bool]):
|
||||
"""Helper function to save email settings to general_settings in db"""
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
|
||||
proxy_config.reject_config_owned_writes(
|
||||
section_name="general_settings", changed_keys={"email_settings": settings}
|
||||
)
|
||||
try:
|
||||
verbose_proxy_logger.debug(
|
||||
f"Saving email settings to general_settings: {settings}"
|
||||
|
|
@ -168,6 +173,8 @@ async def update_event_settings(
|
|||
await _save_email_settings(prisma_client, settings_dict)
|
||||
|
||||
return {"message": "Email event settings updated successfully"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error updating email settings: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
@ -197,6 +204,8 @@ async def reset_event_settings(
|
|||
await _save_email_settings(prisma_client, default_settings)
|
||||
|
||||
return {"message": "Email event settings reset to defaults"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error resetting email settings: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from typing import (
|
|||
)
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
)
|
||||
from openai.types.file_deleted import FileDeleted
|
||||
|
||||
from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend
|
||||
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints
|
||||
from litellm.llms.base_llm.managed_resources.isolation import (
|
||||
build_list_page,
|
||||
|
|
@ -59,6 +61,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
|
|||
get_content_type_from_file_object,
|
||||
get_model_id_from_unified_batch_id,
|
||||
get_original_file_id,
|
||||
is_litellm_executed_batch,
|
||||
map_raw_file_ids_to_unified,
|
||||
normalize_mime_type_for_provider,
|
||||
resolve_managed_output_file_model_name,
|
||||
|
|
@ -75,6 +78,7 @@ from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccess
|
|||
CreateFileRequest,
|
||||
FileListPage,
|
||||
FileObject,
|
||||
HttpxBinaryResponseContent,
|
||||
OpenAIFileObject,
|
||||
ResponsesAPIResponse,
|
||||
)
|
||||
|
|
@ -86,10 +90,6 @@ from litellm.types.utils import (
|
|||
SpecialEnums,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import Span as _Span
|
||||
from prisma.models import (
|
||||
|
|
@ -204,6 +204,19 @@ def _managed_object_table(prisma_client: PrismaClient) -> _ManagedObjectTableAct
|
|||
return prisma_client.db.litellm_managedobjecttable
|
||||
|
||||
|
||||
def _storage_metadata_of(file_object: OpenAIFileObject | None) -> Mapping[str, str]:
|
||||
hidden_params: Final = cast( # cast-ok: _hidden_params is an untyped attribute the upload path sets
|
||||
"Mapping[str, object]", getattr(file_object, "_hidden_params", None) or {}
|
||||
)
|
||||
return MappingProxyType(
|
||||
{
|
||||
key: value
|
||||
for key in ("storage_backend", "storage_url")
|
||||
if isinstance(value := hidden_params.get(key), str)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
||||
# Class variables or attributes
|
||||
def __init__(self, internal_usage_cache: InternalUsageCache, prisma_client: PrismaClient):
|
||||
|
|
@ -226,6 +239,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> None:
|
||||
verbose_logger.info(f"Storing LiteLLM Managed File object with id={file_id} in cache")
|
||||
storage_metadata: Final = _storage_metadata_of(file_object)
|
||||
if file_object is not None:
|
||||
litellm_managed_file_object = LiteLLM_ManagedFileTable(
|
||||
unified_file_id=file_id,
|
||||
|
|
@ -235,6 +249,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
created_by=resolve_resource_owner_id(user_api_key_dict),
|
||||
team_id=user_api_key_dict.team_id,
|
||||
updated_by=user_api_key_dict.user_id,
|
||||
storage_backend=storage_metadata.get("storage_backend"),
|
||||
storage_url=storage_metadata.get("storage_url"),
|
||||
)
|
||||
await self.internal_usage_cache.async_set_cache(
|
||||
key=file_id,
|
||||
|
|
@ -262,14 +278,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
file_object_json = file_object.model_dump_json()
|
||||
db_data["file_object"] = file_object_json
|
||||
update_data["file_object"] = file_object_json
|
||||
# Extract storage metadata from hidden params if present
|
||||
hidden_params = getattr(file_object, "_hidden_params", {}) or {}
|
||||
if "storage_backend" in hidden_params:
|
||||
db_data["storage_backend"] = hidden_params["storage_backend"]
|
||||
update_data["storage_backend"] = hidden_params["storage_backend"]
|
||||
if "storage_url" in hidden_params:
|
||||
db_data["storage_url"] = hidden_params["storage_url"]
|
||||
update_data["storage_url"] = hidden_params["storage_url"]
|
||||
db_data.update(storage_metadata)
|
||||
update_data.update(storage_metadata)
|
||||
|
||||
verbose_logger.debug(
|
||||
f"Storage metadata: storage_backend={db_data.get('storage_backend')}, "
|
||||
|
|
@ -314,6 +324,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
request_tags: Sequence[str] | None = None,
|
||||
persist_attribution: bool = False,
|
||||
create_if_missing: bool = True,
|
||||
batch_processed: bool = False,
|
||||
) -> None:
|
||||
"""Persist a managed object row, caching it and upserting it in the DB.
|
||||
|
||||
|
|
@ -328,6 +339,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
row absent from the table is left absent rather than created with the
|
||||
observer as its creator, because created_by and team_id are written from
|
||||
whoever calls the create branch.
|
||||
|
||||
batch_processed is set by callers that have already billed the batch
|
||||
themselves, so CheckBatchCost skips the row instead of billing it twice.
|
||||
It is written only in the upsert create branch.
|
||||
"""
|
||||
verbose_logger.info(f"Storing LiteLLM Managed {file_purpose} object with id={unified_object_id} in cache")
|
||||
litellm_managed_object = LiteLLM_ManagedObjectTable(
|
||||
|
|
@ -379,6 +394,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
"updated_by": user_api_key_dict.user_id,
|
||||
"status": file_object.status,
|
||||
**attribution_columns,
|
||||
"batch_processed": batch_processed,
|
||||
},
|
||||
"update": update_columns,
|
||||
},
|
||||
|
|
@ -1343,6 +1359,9 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
self, data: Dict, user_api_key_dict: UserAPIKeyAuth, response: LLMResponseTypes
|
||||
) -> LLMResponseTypes:
|
||||
if isinstance(response, LiteLLMBatch):
|
||||
decoded_batch_id: Final = _is_base64_encoded_unified_file_id(response.id)
|
||||
if decoded_batch_id and is_litellm_executed_batch(decoded_batch_id):
|
||||
return response
|
||||
## Check if unified_file_id is in the response
|
||||
unified_file_id = response._hidden_params.get("unified_file_id") # managed file id
|
||||
unified_batch_id = response._hidden_params.get("unified_batch_id") # managed batch id
|
||||
|
|
@ -1794,24 +1813,11 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
# Check if file deletion should be blocked due to batch references
|
||||
await self._check_file_deletion_allowed(file_id)
|
||||
|
||||
# file_id = convert_b64_uid_to_unified_uid(file_id)
|
||||
model_file_id_mapping = await self.get_model_file_id_mapping([file_id], litellm_parent_otel_span)
|
||||
|
||||
specific_model_file_id_mapping = model_file_id_mapping.get(file_id)
|
||||
if specific_model_file_id_mapping:
|
||||
# Remove conflicting keys from data to avoid duplicate keyword arguments
|
||||
filtered_data = {k: v for k, v in data.items() if k not in ("model", "file_id")}
|
||||
for model_id, model_file_id in specific_model_file_id_mapping.items():
|
||||
credentials = llm_router.get_deployment_credentials_with_provider(model_id=model_id)
|
||||
delete_data = {
|
||||
**{k: v for k, v in filtered_data.items() if k != "_litellm_internal_model_credentials"},
|
||||
**(
|
||||
{"_litellm_internal_model_credentials": MappingProxyType(dict(credentials))}
|
||||
if credentials is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
await llm_router.afile_delete(model=model_id, file_id=model_file_id, **delete_data)
|
||||
managed_file: Final = await self.get_unified_file_id(file_id, litellm_parent_otel_span)
|
||||
if managed_file is not None and managed_file.storage_backend and managed_file.storage_url:
|
||||
await self._delete_storage_backend_content(managed_file.storage_backend, managed_file.storage_url)
|
||||
else:
|
||||
await self._delete_provider_files(file_id, litellm_parent_otel_span, llm_router, data)
|
||||
|
||||
await self.delete_unified_file_id(file_id, litellm_parent_otel_span)
|
||||
|
||||
|
|
@ -1820,16 +1826,53 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
prom_logger.record_managed_file_deleted(result="success")
|
||||
return FileDeleted(id=file_id, object="file", deleted=True)
|
||||
|
||||
async def _delete_storage_backend_content(self, storage_backend_name: str, storage_url: str) -> None:
|
||||
try:
|
||||
storage_backend: Final = get_storage_backend(storage_backend_name, prisma_client=self.prisma_client)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Cannot delete the stored file content: {e}") from e
|
||||
await storage_backend.delete_file(storage_url)
|
||||
|
||||
async def _delete_provider_files(
|
||||
self,
|
||||
file_id: str,
|
||||
litellm_parent_otel_span: Span | None,
|
||||
llm_router: Router,
|
||||
data: Mapping[str, object],
|
||||
) -> None:
|
||||
model_file_id_mapping: Final = await self.get_model_file_id_mapping([file_id], litellm_parent_otel_span)
|
||||
specific_model_file_id_mapping: Final = model_file_id_mapping.get(file_id)
|
||||
if not specific_model_file_id_mapping:
|
||||
return
|
||||
filtered_data: Final = {
|
||||
k: v for k, v in data.items() if k not in ("model", "file_id", "_litellm_internal_model_credentials")
|
||||
}
|
||||
for model_id, model_file_id in specific_model_file_id_mapping.items():
|
||||
credentials = llm_router.get_deployment_credentials_with_provider(model_id=model_id)
|
||||
delete_data = {
|
||||
**filtered_data,
|
||||
**(
|
||||
{"_litellm_internal_model_credentials": MappingProxyType(dict(credentials))}
|
||||
if credentials is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
await llm_router.afile_delete(model=model_id, file_id=model_file_id, **delete_data)
|
||||
|
||||
async def afile_content(
|
||||
self,
|
||||
file_id: str,
|
||||
litellm_parent_otel_span: Optional[Span],
|
||||
llm_router: Router,
|
||||
**data: Dict,
|
||||
) -> "HttpxBinaryResponseContent":
|
||||
) -> HttpxBinaryResponseContent:
|
||||
"""
|
||||
Get the content of a file from first model that has it
|
||||
"""
|
||||
managed_file: Final = await self.get_unified_file_id(file_id, litellm_parent_otel_span)
|
||||
if managed_file is not None and managed_file.storage_backend and managed_file.storage_url:
|
||||
return await self._storage_backend_content(managed_file.storage_backend, managed_file.storage_url)
|
||||
|
||||
model_file_id_mapping = data.pop("model_file_id_mapping", None)
|
||||
model_file_id_mapping = model_file_id_mapping or await self.get_model_file_id_mapping(
|
||||
[file_id], litellm_parent_otel_span
|
||||
|
|
@ -1859,6 +1902,11 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
else:
|
||||
raise Exception(f"LiteLLM Managed File object with id={file_id} not found")
|
||||
|
||||
async def _storage_backend_content(self, storage_backend_name: str, storage_url: str) -> HttpxBinaryResponseContent:
|
||||
storage_backend: Final = get_storage_backend(storage_backend_name, prisma_client=self.prisma_client)
|
||||
content: Final = await storage_backend.download_file(storage_url)
|
||||
return HttpxBinaryResponseContent(response=httpx.Response(status_code=httpx.codes.OK, content=content))
|
||||
|
||||
async def _convert_storage_files_to_base64(
|
||||
self,
|
||||
messages: List[AllMessageValues],
|
||||
|
|
@ -1889,16 +1937,12 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
|
||||
# File is stored in a storage backend, download and convert to base64
|
||||
try:
|
||||
from litellm.llms.base_llm.files.storage_backend_factory import (
|
||||
get_storage_backend,
|
||||
)
|
||||
|
||||
storage_backend_name = db_file.storage_backend
|
||||
storage_url = db_file.storage_url
|
||||
|
||||
# Get storage backend (uses same env vars as callback)
|
||||
try:
|
||||
storage_backend = get_storage_backend(storage_backend_name)
|
||||
storage_backend = get_storage_backend(storage_backend_name, prisma_client=self.prisma_client)
|
||||
except ValueError as e:
|
||||
verbose_logger.warning(
|
||||
f"Storage backend '{storage_backend_name}' error for file {file_id}: {str(e)}"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.68"
|
||||
version = "0.1.69"
|
||||
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.68"
|
||||
version = "0.1.69"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE "LiteLLM_AutoRouterSession"
|
||||
ADD COLUMN IF NOT EXISTS "savings_estimated_turns" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS "savings_estimated_actual_spend" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS "savings_estimated_saved_spend" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS "savings_estimated_baseline_models" JSONB NOT NULL DEFAULT '{}';
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
CREATE TABLE IF NOT EXISTS "LiteLLM_AutoRouterBaselineComparison" (
|
||||
"scope" TEXT PRIMARY KEY,
|
||||
"api_key" TEXT NOT NULL,
|
||||
"session_id" TEXT NOT NULL,
|
||||
"router_name" TEXT NOT NULL,
|
||||
"initial_equivalent" BOOLEAN NOT NULL,
|
||||
"revision" BIGINT NOT NULL DEFAULT 0,
|
||||
"published_revision" BIGINT NOT NULL DEFAULT 0,
|
||||
"history" TEXT,
|
||||
"attempted_at" TIMESTAMP(3),
|
||||
"retired" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_autorouter_baseline_scope"
|
||||
ON "LiteLLM_AutoRouterBaselineComparison" ("api_key", "session_id", "router_name");
|
||||
CREATE INDEX IF NOT EXISTS "idx_autorouter_baseline_updated"
|
||||
ON "LiteLLM_AutoRouterBaselineComparison" ("updated_at");
|
||||
CREATE INDEX IF NOT EXISTS "idx_autorouter_baseline_dirty"
|
||||
ON "LiteLLM_AutoRouterBaselineComparison" ("attempted_at", "updated_at", "scope")
|
||||
WHERE NOT "retired" AND "revision" <> "published_revision";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "LiteLLM_AutoRouterBaselineObservation" (
|
||||
"request_id" TEXT PRIMARY KEY,
|
||||
"scope" TEXT NOT NULL,
|
||||
"started_at" DOUBLE PRECISION NOT NULL,
|
||||
"revision" BIGINT NOT NULL,
|
||||
"data" TEXT NOT NULL,
|
||||
"publication" TEXT,
|
||||
"conflicted" BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_autorouter_baseline_event_order"
|
||||
ON "LiteLLM_AutoRouterBaselineObservation" ("scope", "started_at", "request_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_autorouter_baseline_event_revision"
|
||||
ON "LiteLLM_AutoRouterBaselineObservation" ("scope", "revision", "started_at");
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "LiteLLM_ManagedFileContentTable" (
|
||||
"id" TEXT NOT NULL,
|
||||
"content" BYTEA NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LiteLLM_ManagedFileContentTable_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
|
@ -1107,6 +1107,12 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t
|
|||
@@index([team_id, created_at(sort: Desc)])
|
||||
}
|
||||
|
||||
model LiteLLM_ManagedFileContentTable {
|
||||
id String @id @default(uuid())
|
||||
content Bytes
|
||||
created_at DateTime @default(now())
|
||||
}
|
||||
|
||||
model LiteLLM_ManagedVectorStoreTable {
|
||||
id String @id @default(uuid())
|
||||
unified_resource_id String @unique // The base64 encoded unified vector store ID
|
||||
|
|
@ -1545,6 +1551,36 @@ model LiteLLM_AdaptiveRouterSession {
|
|||
@@index([last_activity_at], map: "idx_adaptive_router_session_activity")
|
||||
}
|
||||
|
||||
model LiteLLM_AutoRouterBaselineComparison {
|
||||
scope String @id
|
||||
api_key String
|
||||
session_id String
|
||||
router_name String
|
||||
initial_equivalent Boolean
|
||||
revision BigInt @default(0)
|
||||
published_revision BigInt @default(0)
|
||||
history String?
|
||||
attempted_at DateTime?
|
||||
retired Boolean @default(false)
|
||||
updated_at DateTime @default(now())
|
||||
|
||||
@@index([api_key, session_id, router_name], map: "idx_autorouter_baseline_scope")
|
||||
@@index([updated_at], map: "idx_autorouter_baseline_updated")
|
||||
}
|
||||
|
||||
model LiteLLM_AutoRouterBaselineObservation {
|
||||
request_id String @id
|
||||
scope String
|
||||
started_at Float
|
||||
revision BigInt
|
||||
data String
|
||||
publication String?
|
||||
conflicted Boolean @default(false)
|
||||
|
||||
@@index([scope, started_at, request_id], map: "idx_autorouter_baseline_event_order")
|
||||
@@index([scope, revision, started_at], map: "idx_autorouter_baseline_event_revision")
|
||||
}
|
||||
|
||||
model LiteLLM_AutoRouterSession {
|
||||
api_key String
|
||||
session_id String
|
||||
|
|
@ -1571,6 +1607,10 @@ model LiteLLM_AutoRouterSession {
|
|||
total_tokens BigInt @default(0)
|
||||
spend Float @default(0)
|
||||
saved_spend Float @default(0)
|
||||
savings_estimated_turns Int @default(0)
|
||||
savings_estimated_actual_spend Float @default(0)
|
||||
savings_estimated_saved_spend Float @default(0)
|
||||
savings_estimated_baseline_models Json @default("{}")
|
||||
classifier_cost Float @default(0)
|
||||
classifier_cost_recorded_turns Int @default(0)
|
||||
tier_turns Json @default("{}")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.99"
|
||||
version = "0.4.100"
|
||||
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.99"
|
||||
version = "0.4.100"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
5
litellm-rust/Cargo.lock
generated
5
litellm-rust/Cargo.lock
generated
|
|
@ -2030,7 +2030,7 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-callbacks-legacy"
|
||||
name = "litellm-callbacks-legacy-python"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"litellm-auth",
|
||||
|
|
@ -2192,7 +2192,7 @@ dependencies = [
|
|||
"futures-util",
|
||||
"litellm-auth",
|
||||
"litellm-auth-gcp",
|
||||
"litellm-callbacks-legacy",
|
||||
"litellm-callbacks-legacy-python",
|
||||
"litellm-core",
|
||||
"litellm-core-utils",
|
||||
"litellm-host-python",
|
||||
|
|
@ -3046,7 +3046,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.15",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ repository = "https://github.com/BerriAI/litellm"
|
|||
[workspace.dependencies]
|
||||
litellm-core = { path = "crates/core" }
|
||||
litellm-host = { path = "crates/host" }
|
||||
litellm-callbacks-legacy = { path = "crates/callbacks-legacy" }
|
||||
litellm-callbacks-legacy-python = { path = "crates/callbacks-legacy-python" }
|
||||
litellm-framing = { path = "crates/framer" }
|
||||
litellm-auth = { path = "crates/auth" }
|
||||
litellm-auth-aws = { path = "crates/auth-aws" }
|
||||
|
|
@ -34,7 +34,7 @@ pyo3 = "0.29.2"
|
|||
pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] }
|
||||
pythonize = "0.29.0"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "http2", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "http2", "stream"] }
|
||||
rstest = "0.26.1"
|
||||
rstest_reuse = "0.7.0"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
|
|
|
|||
10
litellm-rust/clippy.toml
Normal file
10
litellm-rust/clippy.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# The Tokio runtime is reached only through `host-python/src/execution.rs`, whose fork gate
|
||||
# must see every entry. Going around it makes a fork-after-use hang instead of raising.
|
||||
disallowed-methods = [
|
||||
{ path = "pyo3_async_runtimes::tokio::get_runtime", reason = "use litellm_host_python::run_sync / run_sync_value" },
|
||||
{ path = "pyo3_async_runtimes::tokio::future_into_py", reason = "use litellm_host_python::run_async / run_async_value" },
|
||||
{ path = "pyo3_async_runtimes::tokio::future_into_py_with_locals", reason = "use litellm_host_python::run_async / run_async_value" },
|
||||
{ path = "pyo3_async_runtimes::tokio::local_future_into_py", reason = "use litellm_host_python::run_async / run_async_value" },
|
||||
{ path = "pyo3_async_runtimes::tokio::run", reason = "use litellm_host_python::run_sync / run_sync_value" },
|
||||
{ path = "pyo3_async_runtimes::tokio::run_until_complete", reason = "use litellm_host_python::run_sync / run_sync_value" },
|
||||
]
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "litellm-callbacks-legacy"
|
||||
name = "litellm-callbacks-legacy-python"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
@ -19,9 +19,9 @@ use serde_json::Value;
|
|||
use crate::{
|
||||
DeploymentHooks, LegacyCallbacks, PublicCall, PythonLogger,
|
||||
deferred::{PendingLogging, PendingSuccess},
|
||||
finalize, is_internal_call,
|
||||
legacy_python::Streaming,
|
||||
prepare, setup,
|
||||
finalize, is_internal_call, prepare,
|
||||
python::Streaming,
|
||||
setup,
|
||||
};
|
||||
|
||||
/// What the legacy contract needs to know about the route it is logging.
|
||||
|
|
@ -6,8 +6,8 @@ use litellm_host::event::{RequestContext, WireRequest};
|
|||
use litellm_host_python::to_py;
|
||||
use pyo3::{exceptions::PyBaseException, prelude::*, types::PyDict};
|
||||
|
||||
use crate::legacy_python::{Logging, Wrapper};
|
||||
use crate::logger::PythonLogger;
|
||||
use crate::python::{Logging, Wrapper};
|
||||
|
||||
pub trait LegacyCallbacks {
|
||||
/// `Logging.update_from_kwargs`: what the logger is told about the request it is
|
||||
|
|
@ -13,9 +13,9 @@ mod adapter;
|
|||
mod call;
|
||||
mod callbacks;
|
||||
mod deferred;
|
||||
mod legacy_python;
|
||||
mod logger;
|
||||
mod preparation;
|
||||
mod python;
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/support.rs"]
|
||||
mod test_support;
|
||||
|
|
@ -5,7 +5,7 @@ use pyo3::{
|
|||
types::{PyDict, PyTuple},
|
||||
};
|
||||
|
||||
use crate::legacy_python::{self, Wrapper};
|
||||
use crate::python::{self, Wrapper};
|
||||
|
||||
/// The `Logging` instance one call fans out through.
|
||||
pub struct PythonLogger {
|
||||
|
|
@ -90,7 +90,7 @@ impl DeploymentHooks {
|
|||
kwargs: &Py<PyDict>,
|
||||
call_type: &str,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
legacy_python::DeploymentHooks::BeforeDeploymentCall
|
||||
python::DeploymentHooks::BeforeDeploymentCall
|
||||
.call(py, (kwargs, call_type))
|
||||
.map(Bound::unbind)
|
||||
}
|
||||
|
|
@ -101,7 +101,7 @@ impl DeploymentHooks {
|
|||
response: &Option<Py<PyAny>>,
|
||||
call_type: &str,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
legacy_python::DeploymentHooks::AfterDeploymentSuccess
|
||||
python::DeploymentHooks::AfterDeploymentSuccess
|
||||
.call(py, (kwargs, response, call_type))
|
||||
.map(Bound::unbind)
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ impl DeploymentHooks {
|
|||
error: &Py<PyBaseException>,
|
||||
call_type: &str,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
legacy_python::DeploymentHooks::AfterDeploymentFailure
|
||||
python::DeploymentHooks::AfterDeploymentFailure
|
||||
.call(py, (kwargs, error, call_type))
|
||||
.map(Bound::unbind)
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ use pyo3::{
|
|||
types::{PyDict, PyList},
|
||||
};
|
||||
|
||||
use crate::legacy_python::Wrapper;
|
||||
use crate::python::Wrapper;
|
||||
|
||||
struct CredentialEntry<'py>(Bound<'py, PyAny>);
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use pyo3::prelude::*;
|
||||
use strum::{IntoStaticStr, VariantArray};
|
||||
|
||||
const MODULE: &str = "litellm.rust_bridge.legacy_callbacks";
|
||||
const MODULE: &str = "litellm.rust_bridge.callbacks_legacy_python";
|
||||
|
||||
/// Every litellm Python internal the native call still borrows, grouped by the subsystem it
|
||||
/// belongs to. Rust drives the call; these exist only so behaviour that Python owns today
|
||||
|
|
@ -9,7 +9,7 @@ const MODULE: &str = "litellm.rust_bridge.legacy_callbacks";
|
|||
/// A group is deleted once Rust owns that subsystem, so this enum only shrinks. Calling a
|
||||
/// user's own callback is not borrowing and does not belong here.
|
||||
///
|
||||
/// `litellm/rust_bridge/legacy_callbacks.py` is the only Python module behind it, and
|
||||
/// `litellm/rust_bridge/callbacks_legacy_python.py` is the only Python module behind it, and
|
||||
/// `python_contract.json` pins each function's parameters on both sides.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum LegacyPython {
|
||||
|
|
@ -5,12 +5,12 @@ use pyo3::types::{PyDict, PyTuple};
|
|||
|
||||
use crate::{LegacyLogging, LegacySurface, PublicCall};
|
||||
|
||||
/// The parameters of every `legacy_callbacks` function, as the real module declares them.
|
||||
/// `tests/test_litellm/rust_bridge/test_legacy_callbacks.py` pins this file to the Python
|
||||
/// The parameters of every `callbacks_legacy_python` function, as the real module declares them.
|
||||
/// `tests/test_litellm/rust_bridge/test_callbacks_legacy_python.py` pins this file to the Python
|
||||
/// signatures, and [`namespace`] binds every fake call against it.
|
||||
pub(crate) const PYTHON_CONTRACT: &str = include_str!("../python_contract.json");
|
||||
|
||||
/// Stand-ins for `legacy_callbacks`, the only Python module the crate calls. Tests
|
||||
/// Stand-ins for `callbacks_legacy_python`, the only Python module the crate calls. Tests
|
||||
/// share one interpreter and run concurrently, so each fake is installed idempotently and
|
||||
/// forwards to the per-test `StubLogger` it is handed (directly, or as `kwargs['logger']`).
|
||||
/// Every fake is bound against the contract first, so a call the real module would reject
|
||||
|
|
@ -23,10 +23,10 @@ import sys
|
|||
import traceback
|
||||
import types
|
||||
|
||||
for name in ('litellm', 'litellm.rust_bridge', 'litellm.rust_bridge.legacy_callbacks'):
|
||||
for name in ('litellm', 'litellm.rust_bridge', 'litellm.rust_bridge.callbacks_legacy_python'):
|
||||
sys.modules.setdefault(name, types.ModuleType(name))
|
||||
|
||||
legacy = sys.modules['litellm.rust_bridge.legacy_callbacks']
|
||||
legacy = sys.modules['litellm.rust_bridge.callbacks_legacy_python']
|
||||
CONTRACT = json.loads(python_contract)
|
||||
|
||||
|
||||
|
|
@ -4,6 +4,7 @@ use std::pin::Pin;
|
|||
use std::task::{Context, Poll, Waker};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::fork_gate::{ForkGate, Refused, RuntimeAlreadyStarted};
|
||||
use crate::{Pythonized, panic_to_pyerr, release_gil};
|
||||
use futures_util::FutureExt;
|
||||
use pyo3::exceptions::PyRuntimeError;
|
||||
|
|
@ -12,6 +13,67 @@ use serde::Serialize;
|
|||
use tokio::runtime::{Handle, Runtime};
|
||||
use tokio::time::{self, MissedTickBehavior};
|
||||
|
||||
pyo3::create_exception!(
|
||||
_native,
|
||||
ForkedAfterNativeRuntimeStarted,
|
||||
PyRuntimeError,
|
||||
"This process was forked after the native runtime started. Runtime threads do not survive fork(), so native routes cannot run here."
|
||||
);
|
||||
|
||||
pyo3::create_exception!(
|
||||
_native,
|
||||
ProcessReservedForForking,
|
||||
PyRuntimeError,
|
||||
"This process was reserved for forking workers, so native routes cannot run here."
|
||||
);
|
||||
|
||||
static FORK_GATE: ForkGate = ForkGate::new();
|
||||
|
||||
/// Whether this process has started the Tokio runtime.
|
||||
pub fn runtime_started() -> bool {
|
||||
FORK_GATE.started(std::process::id())
|
||||
}
|
||||
|
||||
/// Declares that this process exists to fork workers, so it must never start the runtime.
|
||||
/// Fails if it already has. Workers are unaffected: the reservation is keyed by pid.
|
||||
pub fn reserve_process_for_forking() -> Result<(), RuntimeAlreadyStarted> {
|
||||
FORK_GATE.reserve(std::process::id())
|
||||
}
|
||||
|
||||
/// The only door to the Tokio runtime: every route reaches it through this module, which is
|
||||
/// what lets the gate speak for the whole extension. `clippy.toml` disallows going around it.
|
||||
fn enter_runtime() -> PyResult<()> {
|
||||
FORK_GATE
|
||||
.enter(std::process::id())
|
||||
.map_err(|refused| match refused {
|
||||
Refused::ReservedForForking => ProcessReservedForForking::new_err(
|
||||
"this process is reserved for forking workers and cannot run native routes; \
|
||||
move the call into a worker, after the fork",
|
||||
),
|
||||
Refused::ForkedAfterStart => ForkedAfterNativeRuntimeStarted::new_err(
|
||||
"this process was forked after the native runtime started, and runtime threads \
|
||||
do not survive fork(); start workers with spawn or forkserver, or fork before \
|
||||
the first native call",
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(clippy::disallowed_methods, reason = "this is the gated door")]
|
||||
fn runtime() -> PyResult<&'static Runtime> {
|
||||
enter_runtime()?;
|
||||
Ok(pyo3_async_runtimes::tokio::get_runtime())
|
||||
}
|
||||
|
||||
#[expect(clippy::disallowed_methods, reason = "this is the gated door")]
|
||||
fn future_into_py<F, T>(py: Python<'_>, future: F) -> PyResult<Bound<'_, PyAny>>
|
||||
where
|
||||
F: Future<Output = PyResult<T>> + Send + 'static,
|
||||
T: for<'py> IntoPyObject<'py> + Send + 'static,
|
||||
{
|
||||
enter_runtime()?;
|
||||
pyo3_async_runtimes::tokio::future_into_py(py, future)
|
||||
}
|
||||
|
||||
pub fn run_sync<T, E, F>(
|
||||
py: Python<'_>,
|
||||
future: F,
|
||||
|
|
@ -22,12 +84,7 @@ where
|
|||
E: Send + 'static,
|
||||
F: Future<Output = Result<T, E>> + Send + 'static,
|
||||
{
|
||||
run_sync_on(
|
||||
py,
|
||||
pyo3_async_runtimes::tokio::get_runtime(),
|
||||
future,
|
||||
map_error,
|
||||
)
|
||||
run_sync_on(py, runtime()?, future, map_error)
|
||||
}
|
||||
|
||||
pub fn run_sync_value<T, F>(py: Python<'_>, future: F) -> PyResult<T>
|
||||
|
|
@ -35,7 +92,7 @@ where
|
|||
T: Send + 'static,
|
||||
F: Future<Output = PyResult<T>> + Send + 'static,
|
||||
{
|
||||
run_sync_value_on(py, pyo3_async_runtimes::tokio::get_runtime(), future)
|
||||
run_sync_value_on(py, runtime()?, future)
|
||||
}
|
||||
|
||||
fn run_sync_value_on<T, F>(py: Python<'_>, runtime: &Runtime, future: F) -> PyResult<T>
|
||||
|
|
@ -83,7 +140,7 @@ where
|
|||
E: Send + 'static,
|
||||
F: Future<Output = Result<T, E>> + Send + 'static,
|
||||
{
|
||||
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||
future_into_py(py, async move {
|
||||
let result = catch_future_panic(future).await?;
|
||||
let result = map_core_result(result, map_error)?;
|
||||
Ok(Pythonized(result))
|
||||
|
|
@ -95,7 +152,7 @@ where
|
|||
T: for<'py> IntoPyObject<'py> + Send + 'static,
|
||||
F: Future<Output = PyResult<T>> + Send + 'static,
|
||||
{
|
||||
pyo3_async_runtimes::tokio::future_into_py(py, async move { catch_future_panic(future).await? })
|
||||
future_into_py(py, async move { catch_future_panic(future).await? })
|
||||
}
|
||||
|
||||
pub fn poll_async_value<T, F>(py: Python<'_>, future: Pin<&mut F>) -> PyResult<Poll<T>>
|
||||
|
|
@ -103,8 +160,9 @@ where
|
|||
T: Send,
|
||||
F: Future<Output = PyResult<T>> + Send,
|
||||
{
|
||||
let runtime = runtime()?;
|
||||
let result = release_gil(py, || {
|
||||
let _runtime = pyo3_async_runtimes::tokio::get_runtime().enter();
|
||||
let _runtime = runtime.enter();
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
future.poll(&mut Context::from_waker(Waker::noop()))
|
||||
}))
|
||||
|
|
@ -286,27 +344,25 @@ mod tests {
|
|||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn runtime_worker_count() -> usize {
|
||||
pyo3_async_runtimes::tokio::get_runtime()
|
||||
.metrics()
|
||||
.num_workers()
|
||||
fn runtime_worker_count() -> PyResult<usize> {
|
||||
Ok(runtime()?.metrics().num_workers())
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn runtime_is_responsive(_py: Python<'_>, expected_completions: usize) -> bool {
|
||||
fn runtime_is_responsive(_py: Python<'_>, expected_completions: usize) -> PyResult<bool> {
|
||||
let completion_deadline = Instant::now() + Duration::from_secs(2);
|
||||
while ASYNC_PROBE_COMPLETED.load(Ordering::SeqCst) < expected_completions {
|
||||
if Instant::now() >= completion_deadline {
|
||||
return false;
|
||||
return Ok(false);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
|
||||
let (heartbeat_tx, heartbeat_rx) = mpsc::sync_channel(1);
|
||||
pyo3_async_runtimes::tokio::get_runtime().spawn(async move {
|
||||
runtime()?.spawn(async move {
|
||||
let _ = heartbeat_tx.send(());
|
||||
});
|
||||
heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok()
|
||||
Ok(heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok())
|
||||
}
|
||||
|
||||
fn extract_bool(py: Python<'_>, result: PyResult<Py<PyAny>>) -> bool {
|
||||
|
|
@ -317,6 +373,16 @@ mod tests {
|
|||
.expect("result should convert")
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn reaching_the_runtime_marks_the_process_as_started(
|
||||
#[from(initialized_python)] python: &InitializedPython,
|
||||
) {
|
||||
python.attach(|py| {
|
||||
run_sync_value(py, async { Ok(()) }).unwrap();
|
||||
assert!(runtime_started());
|
||||
});
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn inline_poll_releases_gil_and_enters_runtime(
|
||||
#[from(initialized_python)] python: &InitializedPython,
|
||||
|
|
|
|||
139
litellm-rust/crates/host-python/src/fork_gate.rs
Normal file
139
litellm-rust/crates/host-python/src/fork_gate.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
const UNSET: u32 = 0;
|
||||
|
||||
/// Decides which process may use the Tokio runtime. Its worker threads do not survive
|
||||
/// `fork()`: a child forked after they started hangs on its first native call. The gate turns
|
||||
/// both halves of that hazard into errors, keyed by pid so a fork needs no hook to be seen:
|
||||
/// a process reserved for forking can never start the runtime, and a child of a process that
|
||||
/// did start it is refused instead of hanging.
|
||||
pub(crate) struct ForkGate {
|
||||
runtime_pid: AtomicU32,
|
||||
fork_only_pid: AtomicU32,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Refused {
|
||||
ReservedForForking,
|
||||
ForkedAfterStart,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeAlreadyStarted;
|
||||
|
||||
impl ForkGate {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self {
|
||||
runtime_pid: AtomicU32::new(UNSET),
|
||||
fork_only_pid: AtomicU32::new(UNSET),
|
||||
}
|
||||
}
|
||||
|
||||
/// Claims the runtime for `pid`. Claim first, then look for a reservation: `reserve` does
|
||||
/// the mirror image, so when the two race at least one of them sees the other.
|
||||
pub(crate) fn enter(&self, pid: u32) -> Result<(), Refused> {
|
||||
match self
|
||||
.runtime_pid
|
||||
.compare_exchange(UNSET, pid, Ordering::SeqCst, Ordering::SeqCst)
|
||||
{
|
||||
Err(owner) if owner != pid => return Err(Refused::ForkedAfterStart),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if self.fork_only_pid.load(Ordering::SeqCst) == pid {
|
||||
// Nothing was started, so the workers forked from here must still find it unclaimed.
|
||||
let _ =
|
||||
self.runtime_pid
|
||||
.compare_exchange(pid, UNSET, Ordering::SeqCst, Ordering::SeqCst);
|
||||
return Err(Refused::ReservedForForking);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reserves `pid` for forking. Reserve first, then look for a started runtime: `enter` does
|
||||
/// the mirror image, so when the two race at least one of them sees the other. A refused
|
||||
/// reservation leaves the gate exactly as it was, so a process already running the runtime
|
||||
/// keeps refusing the children it forks.
|
||||
pub(crate) fn reserve(&self, pid: u32) -> Result<(), RuntimeAlreadyStarted> {
|
||||
self.fork_only_pid.store(pid, Ordering::SeqCst);
|
||||
if self.runtime_pid.load(Ordering::SeqCst) == pid {
|
||||
let _ =
|
||||
self.fork_only_pid
|
||||
.compare_exchange(pid, UNSET, Ordering::SeqCst, Ordering::SeqCst);
|
||||
return Err(RuntimeAlreadyStarted);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn started(&self, pid: u32) -> bool {
|
||||
self.runtime_pid.load(Ordering::SeqCst) == pid
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MASTER: u32 = 100;
|
||||
const WORKER: u32 = 101;
|
||||
|
||||
#[test]
|
||||
fn unreserved_process_starts_the_runtime_and_stays_started() {
|
||||
let gate = ForkGate::new();
|
||||
|
||||
assert!(!gate.started(MASTER));
|
||||
assert_eq!(gate.enter(MASTER), Ok(()));
|
||||
assert_eq!(gate.enter(MASTER), Ok(()));
|
||||
assert!(gate.started(MASTER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_process_can_never_start_the_runtime() {
|
||||
let gate = ForkGate::new();
|
||||
|
||||
assert_eq!(gate.reserve(MASTER), Ok(()));
|
||||
assert_eq!(gate.enter(MASTER), Err(Refused::ReservedForForking));
|
||||
assert_eq!(gate.enter(MASTER), Err(Refused::ReservedForForking));
|
||||
assert!(!gate.started(MASTER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workers_forked_from_a_reserved_process_start_their_own_runtime() {
|
||||
let gate = ForkGate::new();
|
||||
gate.reserve(MASTER).unwrap();
|
||||
gate.enter(MASTER).unwrap_err();
|
||||
|
||||
assert_eq!(gate.enter(WORKER), Ok(()));
|
||||
assert!(gate.started(WORKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserving_after_the_runtime_started_is_refused() {
|
||||
let gate = ForkGate::new();
|
||||
gate.enter(MASTER).unwrap();
|
||||
|
||||
assert_eq!(gate.reserve(MASTER), Err(RuntimeAlreadyStarted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_reservation_leaves_the_runtime_claimed_and_its_children_refused() {
|
||||
let gate = ForkGate::new();
|
||||
gate.enter(MASTER).unwrap();
|
||||
|
||||
assert_eq!(gate.reserve(MASTER), Err(RuntimeAlreadyStarted));
|
||||
assert_eq!(gate.enter(MASTER), Ok(()));
|
||||
assert!(gate.started(MASTER));
|
||||
assert_eq!(gate.enter(WORKER), Err(Refused::ForkedAfterStart));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_forked_after_the_runtime_started_is_refused_instead_of_hanging() {
|
||||
let gate = ForkGate::new();
|
||||
gate.enter(MASTER).unwrap();
|
||||
|
||||
assert_eq!(gate.enter(WORKER), Err(Refused::ForkedAfterStart));
|
||||
assert!(!gate.started(WORKER));
|
||||
assert_eq!(gate.enter(MASTER), Ok(()));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ mod argument;
|
|||
mod callable;
|
||||
mod driver;
|
||||
mod execution;
|
||||
mod fork_gate;
|
||||
mod gil;
|
||||
mod handle;
|
||||
mod marshal;
|
||||
|
|
@ -18,7 +19,12 @@ pub use adapter::{
|
|||
pub use argument::lookup;
|
||||
pub use callable::wrap_failure;
|
||||
pub use driver::run_call;
|
||||
pub use execution::{poll_async_value, run_async, run_async_value, run_sync, run_sync_value};
|
||||
pub use execution::{
|
||||
ForkedAfterNativeRuntimeStarted, ProcessReservedForForking, poll_async_value,
|
||||
reserve_process_for_forking, run_async, run_async_value, run_sync, run_sync_value,
|
||||
runtime_started,
|
||||
};
|
||||
pub use fork_gate::RuntimeAlreadyStarted;
|
||||
pub use gil::{release_count, release_gil};
|
||||
pub use handle::{Execution, ExecutionBody, ExecutionStep};
|
||||
pub use marshal::{Pythonized, from_py, from_py_argument, panic_to_pyerr, to_py};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
- Target invariants, not completion claims; these supersede the crate guidance below where they conflict
|
||||
- Keep this crate the product-specific PyO3 consumer of `litellm-host-python`
|
||||
- Own registration, input projection, the route host and the caller callables it answers operations with (file readers, token providers), public response/error construction and the per-call composition of machine, route host and callback contract
|
||||
- Legacy callback sharing (the caller's args, kwargs and request object, body/header roots, re-aliasing unchanged body keys) lives in `litellm-callbacks-legacy` behind `PublicCall` and `run_legacy_call`; the bridge hands the public call over and keeps no copy
|
||||
- Legacy callback sharing (the caller's args, kwargs and request object, body/header roots, re-aliasing unchanged body keys) lives in `litellm-callbacks-legacy-python` behind `PublicCall` and `run_legacy_call`; the bridge hands the public call over and keeps no copy
|
||||
- Value-oriented execution, sync waiting, nested-runtime checks, signal polling and panic containment live in `litellm-host-python`; native async work uses `pyo3-async-runtimes`, Serde output uses `Pythonized<T>`
|
||||
- Core owns typed native state, the route machine, provider preparation/I/O and normalization; the host driver owns terminal events; the legacy adapter in `litellm-callbacks-legacy` owns `Logging` dispatch policy
|
||||
- Core owns typed native state, the route machine, provider preparation/I/O and normalization; the host driver owns terminal events; the legacy adapter in `litellm-callbacks-legacy-python` owns `Logging` dispatch policy
|
||||
- Python, Rust SDK and gateway use one lifecycle-bearing core route entrypoint; provider helpers stay private, never bridge-accessible transport drivers
|
||||
- Built-in provider/config/secret/auth/document preparation stays in Rust; caller-authored callbacks and focused Python-file reads run only at core-selected points
|
||||
- Target GIL-enabled CPython explicitly with `#[pymodule(gil_used = true)]`; detach Rust-only work
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ panic-test = []
|
|||
[dependencies]
|
||||
bytes.workspace = true
|
||||
litellm-auth.workspace = true
|
||||
litellm-callbacks-legacy.workspace = true
|
||||
litellm-callbacks-legacy-python.workspace = true
|
||||
litellm-core.workspace = true
|
||||
litellm-core-utils.workspace = true
|
||||
litellm-auth-gcp.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use litellm_host_python::release_count;
|
||||
use pyo3::{prelude::*, types::PyDict};
|
||||
use litellm_host_python::{release_count, runtime_started};
|
||||
use pyo3::{exceptions::PyRuntimeError, prelude::*, types::PyDict};
|
||||
|
||||
#[pyfunction]
|
||||
pub(crate) fn gil_stats(py: Python<'_>) -> PyResult<Py<PyAny>> {
|
||||
|
|
@ -8,6 +8,20 @@ pub(crate) fn gil_stats(py: Python<'_>) -> PyResult<Py<PyAny>> {
|
|||
Ok(stats.into_any().unbind())
|
||||
}
|
||||
|
||||
/// True once this process has started the native runtime, which does not survive `fork()`.
|
||||
#[pyfunction]
|
||||
pub(crate) fn process_state_started() -> bool {
|
||||
runtime_started()
|
||||
}
|
||||
|
||||
/// Declares that this process only forks workers: from now on every native route raises here,
|
||||
/// so the runtime can never start. Raises if it already has. Forked workers are unaffected.
|
||||
#[pyfunction]
|
||||
pub(crate) fn reserve_process_for_forking() -> PyResult<()> {
|
||||
litellm_host_python::reserve_process_for_forking()
|
||||
.map_err(|_| PyRuntimeError::new_err("the native runtime already started in this process"))
|
||||
}
|
||||
|
||||
#[cfg(feature = "panic-test")]
|
||||
#[pyfunction]
|
||||
pub(crate) fn _panic_for_test() {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ mod _native {
|
|||
#[pymodule_export]
|
||||
use crate::diagnostics::_panic_for_test;
|
||||
#[pymodule_export]
|
||||
use crate::diagnostics::gil_stats;
|
||||
use crate::diagnostics::{gil_stats, process_state_started, reserve_process_for_forking};
|
||||
#[pymodule_export]
|
||||
use crate::errors::{RustBridgeDeclined, RustUpstreamError};
|
||||
#[pymodule_export]
|
||||
|
|
@ -30,6 +30,8 @@ mod _native {
|
|||
use crate::routes::responses::ResponsesWebSocketConnection;
|
||||
#[pymodule_export]
|
||||
use crate::token_counter::TokenCounter;
|
||||
#[pymodule_export]
|
||||
use litellm_host_python::{ForkedAfterNativeRuntimeStarted, ProcessReservedForForking};
|
||||
}
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
|
@ -50,6 +52,8 @@ mod tests {
|
|||
let mut expected = vec![
|
||||
"RustBridgeDeclined",
|
||||
"RustUpstreamError",
|
||||
"ForkedAfterNativeRuntimeStarted",
|
||||
"ProcessReservedForForking",
|
||||
"ocr",
|
||||
"aocr",
|
||||
"transcription",
|
||||
|
|
@ -62,6 +66,8 @@ mod tests {
|
|||
"ResponsesWebSocketConnection",
|
||||
"TokenCounter",
|
||||
"gil_stats",
|
||||
"process_state_started",
|
||||
"reserve_process_for_forking",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
mod host;
|
||||
|
||||
use host::MessagesRouteHost;
|
||||
use litellm_callbacks_legacy::{LegacySurface, PassThroughStream, PublicCall, run_legacy_call};
|
||||
use litellm_callbacks_legacy_python::{
|
||||
LegacySurface, PassThroughStream, PublicCall, run_legacy_call,
|
||||
};
|
||||
use litellm_core::messages::route::{messages_machine, supports};
|
||||
use pyo3::{
|
||||
prelude::*,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use std::sync::{Arc, LazyLock};
|
|||
|
||||
use host::OcrRouteHost;
|
||||
use litellm_auth_gcp::VertexAuth;
|
||||
use litellm_callbacks_legacy::{LegacySurface, PublicCall, run_legacy_call};
|
||||
use litellm_callbacks_legacy_python::{LegacySurface, PublicCall, run_legacy_call};
|
||||
use litellm_core::ocr::route::ocr_machine;
|
||||
use litellm_core_utils::settings::ProcessEnvironment;
|
||||
use litellm_llms::base_llm::ocr::{
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ impl ResponsesWebSocketConnection {
|
|||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let headers = marshal_headers(headers)?;
|
||||
let timeout = optional_timeout(timeout_seconds);
|
||||
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||
litellm_host_python::run_async_value(py, async move {
|
||||
let inner = RustResponsesWebSocketConnection::connect_url(&url, &headers, timeout)
|
||||
.await
|
||||
.map_err(responses_error_to_pyerr)?;
|
||||
|
|
@ -35,7 +35,7 @@ impl ResponsesWebSocketConnection {
|
|||
|
||||
fn send_text<'py>(&self, py: Python<'py>, text: String) -> PyResult<Bound<'py, PyAny>> {
|
||||
let inner = self.inner.clone();
|
||||
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||
litellm_host_python::run_async_value(py, async move {
|
||||
inner
|
||||
.send_text(text)
|
||||
.await
|
||||
|
|
@ -45,14 +45,14 @@ impl ResponsesWebSocketConnection {
|
|||
|
||||
fn recv_text<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||
let inner = self.inner.clone();
|
||||
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||
litellm_host_python::run_async_value(py, async move {
|
||||
inner.recv_text().await.map_err(responses_error_to_pyerr)
|
||||
})
|
||||
}
|
||||
|
||||
fn close<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
||||
let inner = self.inner.clone();
|
||||
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
||||
litellm_host_python::run_async_value(py, async move {
|
||||
inner.close().await.map_err(responses_error_to_pyerr)
|
||||
})
|
||||
}
|
||||
|
|
@ -68,6 +68,10 @@ mod tests {
|
|||
use tokio_tungstenite::{accept_async, tungstenite::Message};
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the test server shares the routes' runtime"
|
||||
)]
|
||||
fn responses_websocket_connection_round_trips_through_python() {
|
||||
Python::initialize();
|
||||
let runtime = pyo3_async_runtimes::tokio::get_runtime();
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ import anthropic
|
|||
# point anthropic sdk to litellm proxy
|
||||
client = anthropic.Anthropic(
|
||||
base_url="http://0.0.0.0:4000",
|
||||
api_key="sk-1234",
|
||||
api_key="<your-master-key>",
|
||||
)
|
||||
|
||||
response = client.messages.create(
|
||||
|
|
|
|||
|
|
@ -1694,6 +1694,7 @@ LOGIN_THROTTLE_NOT_BLOCKED: Final = (0, 0)
|
|||
LITELLM_PROXY_ADMIN_NAME: Final = "default_user_id"
|
||||
LITELLM_PROXY_BUDGET_NAME: Final = "litellm-proxy-budget"
|
||||
GLOBAL_PROXY_SPEND_CACHE_KEY: Final = f"{LITELLM_PROXY_ADMIN_NAME}:spend"
|
||||
LITELLM_EXECUTED_BATCH_CONCURRENCY: Final = max(1, int(os.getenv("LITELLM_EXECUTED_BATCH_CONCURRENCY", "4")))
|
||||
|
||||
########################### CLI SSO AUTHENTICATION CONSTANTS ###########################
|
||||
LITELLM_CLI_SOURCE_IDENTIFIER: Final = "litellm-cli"
|
||||
|
|
|
|||
|
|
@ -183,14 +183,14 @@ def get_provider_container_config(
|
|||
```bash
|
||||
# Create container via Azure
|
||||
curl -X POST "http://localhost:4000/v1/containers" \
|
||||
-H "Authorization: Bearer sk-1234" \
|
||||
-H "Authorization: Bearer <your-master-key>" \
|
||||
-H "custom-llm-provider: azure" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Azure Container"}'
|
||||
|
||||
# List container files via Azure
|
||||
curl -X GET "http://localhost:4000/v1/containers/cntr_123/files" \
|
||||
-H "Authorization: Bearer sk-1234" \
|
||||
-H "Authorization: Bearer <your-master-key>" \
|
||||
-H "custom-llm-provider: azure"
|
||||
```
|
||||
|
||||
|
|
@ -219,12 +219,13 @@ python -m pytest tests/test_litellm/containers/ -v
|
|||
Test via proxy:
|
||||
|
||||
```bash
|
||||
# Start proxy
|
||||
# Start proxy (proxy_config.yaml reads its master key from LITELLM_MASTER_KEY)
|
||||
export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 32)"
|
||||
cd litellm/proxy && python proxy_cli.py --config proxy_config.yaml --port 4000
|
||||
|
||||
# Test endpoints
|
||||
curl -X GET "http://localhost:4000/v1/containers/cntr_123/files" \
|
||||
-H "Authorization: Bearer sk-1234"
|
||||
-H "Authorization: Bearer $LITELLM_MASTER_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -15,3 +15,9 @@ Upgrade SDK1-dependent libraries before installing them alongside `litellm[mcp]`
|
|||
The shared unit-test workflow runs the MCP integration suite once, with SDK2 in the gateway environment and an isolated SDK1 peer. Keep the SDK1 list/call compatibility test while SDK1 clients are supported; remove it when that support is explicitly retired and the client migration is documented
|
||||
|
||||
See the official [SDK migration guide](https://py.sdk.modelcontextprotocol.io/migration/) for Python API changes
|
||||
|
||||
## HTTP redirects
|
||||
|
||||
For streamable HTTP POST requests, the MCP SDK follows method-preserving redirects such as HTTP 307/308 within the configured endpoint's origin. Redirects to another path on the same scheme, host and port work. The SDK also permits an HTTP-to-HTTPS upgrade on the same host using the default ports
|
||||
|
||||
Redirects to a different origin are rejected before the destination receives a request or credentials. Configure the final MCP endpoint URL directly if the server redirects to a different host or port. Setting the HTTP client's `follow_redirects` option does not override the SDK's policy
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import json
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final, TypeAlias, cast
|
||||
from typing import Any, Final, TypeAlias, TypeVar, cast
|
||||
|
||||
from pydantic import JsonValue, TypeAdapter, ValidationError
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm import verbose_logger
|
||||
from litellm.litellm_core_utils.json_validation_rule import normalize_tool_schema
|
||||
from litellm.exceptions import BadRequestError
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
from litellm.litellm_core_utils.get_supported_openai_params import get_supported_openai_params
|
||||
from litellm.litellm_core_utils.json_validation_rule import normalize_json_schema_types, normalize_tool_schema
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import filter_value_from_dict
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ChatCompletionAssistantMessage,
|
||||
|
|
@ -75,6 +80,7 @@ class _GenAIContentPart(TypedDict, total=False):
|
|||
class _GenAIFunctionDeclaration(TypedDict, total=False):
|
||||
name: ReadOnly[str]
|
||||
description: ReadOnly[str]
|
||||
parameters: ReadOnly[object]
|
||||
parametersJsonSchema: ReadOnly[object]
|
||||
|
||||
|
||||
|
|
@ -95,6 +101,48 @@ class _GenAISystemInstruction(TypedDict, total=False):
|
|||
|
||||
|
||||
_EMPTY_STR_MAPPING: Final[Mapping[str, str]] = MappingProxyType({})
|
||||
_RESPONSE_MIME_TYPE_KEYS: Final = ("responseMimeType", "response_mime_type")
|
||||
_RESPONSE_SCHEMA_KEYS: Final = ("responseJsonSchema", "response_json_schema", "responseSchema", "response_schema")
|
||||
_TOOL_PARAMETERS_KEYS: Final = ("parametersJsonSchema", "parameters")
|
||||
_JSON_MIME_TYPE: Final = "application/json"
|
||||
_GEMINI_ONLY_SCHEMA_KEYS: Final = frozenset({"propertyOrdering", "property_ordering"})
|
||||
_CONFIG_FIELDS: Final = TypeAdapter(Mapping[str, object])
|
||||
_JSON_OBJECT_SCHEMA: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_Validated: Final = TypeVar("_Validated")
|
||||
|
||||
|
||||
def _first_present(config: Mapping[str, object], keys: Sequence[str]) -> object | None:
|
||||
return next((config[key] for key in keys if config.get(key) is not None), None)
|
||||
|
||||
|
||||
def _validated(adapter: TypeAdapter[_Validated], value: object) -> _Validated | None:
|
||||
try:
|
||||
return adapter.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _translate_response_format(config: object) -> Mapping[str, object] | None:
|
||||
fields: Final = _validated(_CONFIG_FIELDS, config)
|
||||
if fields is None or _first_present(fields, _RESPONSE_MIME_TYPE_KEYS) not in (None, _JSON_MIME_TYPE):
|
||||
return None
|
||||
schema: Final = _validated(
|
||||
_JSON_OBJECT_SCHEMA, normalize_json_schema_types(_first_present(fields, _RESPONSE_SCHEMA_KEYS))
|
||||
)
|
||||
if schema is None or schema.get("type") != "object":
|
||||
return None
|
||||
for key in _GEMINI_ONLY_SCHEMA_KEYS:
|
||||
filter_value_from_dict(schema, key)
|
||||
return {"type": "json_schema", "json_schema": {"name": "response", "schema": schema}}
|
||||
|
||||
|
||||
def _deployment_supports_response_format(model: str, custom_llm_provider: str | None) -> bool:
|
||||
try:
|
||||
provider_model, provider, _, _ = get_llm_provider(model=model, custom_llm_provider=custom_llm_provider)
|
||||
except BadRequestError:
|
||||
return True
|
||||
supported_params: Final = get_supported_openai_params(model=provider_model, custom_llm_provider=provider)
|
||||
return supported_params is None or "response_format" in supported_params
|
||||
|
||||
|
||||
class GoogleGenAIStreamWrapper(AdapterCompletionStreamWrapper):
|
||||
|
|
@ -314,6 +362,11 @@ class GoogleGenAIAdapter:
|
|||
pass
|
||||
if "stopSequences" in config:
|
||||
completion_request["stop"] = config["stopSequences"]
|
||||
response_format: Final = _translate_response_format(config)
|
||||
if response_format is not None and _deployment_supports_response_format(
|
||||
model, litellm_params.custom_llm_provider if litellm_params else None
|
||||
):
|
||||
completion_request["response_format"] = response_format
|
||||
|
||||
# Handle tools transformation
|
||||
if tools:
|
||||
|
|
@ -390,8 +443,9 @@ class GoogleGenAIAdapter:
|
|||
|
||||
if "description" in func_decl:
|
||||
function_chunk["description"] = func_decl["description"]
|
||||
if "parametersJsonSchema" in func_decl:
|
||||
function_chunk["parameters"] = func_decl["parametersJsonSchema"]
|
||||
parameters = _validated(_JSON_OBJECT_SCHEMA, _first_present(func_decl, _TOOL_PARAMETERS_KEYS))
|
||||
if parameters is not None:
|
||||
function_chunk["parameters"] = parameters
|
||||
|
||||
openai_tool: _JsonDict = {"type": "function", "function": function_chunk}
|
||||
openai_tools.append(openai_tool)
|
||||
|
|
@ -582,14 +636,6 @@ class GoogleGenAIAdapter:
|
|||
),
|
||||
}
|
||||
|
||||
# Add text field for convenience (common in Google GenAI responses)
|
||||
text_content = ""
|
||||
for part in parts:
|
||||
if isinstance(part, dict) and "text" in part:
|
||||
text_content += part["text"]
|
||||
if text_content:
|
||||
generate_content_response["text"] = text_content
|
||||
|
||||
return generate_content_response
|
||||
|
||||
def translate_streaming_completion_to_generate_content(
|
||||
|
|
@ -656,14 +702,6 @@ class GoogleGenAIAdapter:
|
|||
)
|
||||
streaming_chunk["usageMetadata"] = usage_metadata
|
||||
|
||||
# Add text field for convenience (common in Google GenAI responses)
|
||||
text_content = ""
|
||||
for part in parts:
|
||||
if isinstance(part, dict) and "text" in part:
|
||||
text_content += part["text"]
|
||||
if text_content:
|
||||
streaming_chunk["text"] = text_content
|
||||
|
||||
return streaming_chunk
|
||||
|
||||
def _transform_openai_message_to_google_genai_parts(
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ litellm --config config.yaml --detailed_debug
|
|||
```bash
|
||||
curl -L -X POST 'http://0.0.0.0:4000/v1/chat/completions' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer sk-1234' \
|
||||
-H 'Authorization: Bearer <your-master-key>' \
|
||||
-d '{
|
||||
"model": "my-bitbucket-model",
|
||||
"messages": [{"role": "user", "content": "IGNORED"}],
|
||||
|
|
|
|||
|
|
@ -259,6 +259,13 @@
|
|||
"ui_name": "Tracing Environment",
|
||||
"description": "Langfuse tracing environment (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT)",
|
||||
"required": false
|
||||
},
|
||||
"langfuse_span_scope": {
|
||||
"type": "select",
|
||||
"ui_name": "Span Scope",
|
||||
"description": "full sends the whole request trace, llm_only sends just the model-call spans",
|
||||
"options": ["full", "llm_only"],
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"description": "Langfuse v3 OTEL Logging Integration"
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ litellm --config config.yaml --detailed_debug
|
|||
```bash
|
||||
curl -L -X POST 'http://0.0.0.0:4000/v1/chat/completions' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer sk-1234' \
|
||||
-H 'Authorization: Bearer <your-master-key>' \
|
||||
-d '{
|
||||
"model": "my-gitlab-model",
|
||||
"messages": [{"role": "user", "content": "IGNORED"}],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from litellm.integrations.otel.model.baggage import (
|
|||
DEFAULT_BAGGAGE_METADATA_KEYS,
|
||||
DEFAULT_BAGGAGE_TEAM_METADATA_KEYS,
|
||||
)
|
||||
from litellm.types.utils import OtelSpanScope
|
||||
|
||||
#: Master feature-flag env var. The logger is inert until this is truthy.
|
||||
OTEL_V2_ENV: Final = "LITELLM_OTEL_V2"
|
||||
|
|
@ -163,6 +164,15 @@ class OpenTelemetryV2Config(BaseSettings):
|
|||
validation_alias=AliasChoices("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"),
|
||||
)
|
||||
legacy_compat: bool = Field(default=True, validation_alias=AliasChoices("LITELLM_OTEL_LEGACY_COMPAT"))
|
||||
langfuse_span_scope: OtelSpanScope = Field(
|
||||
default="full",
|
||||
validation_alias=AliasChoices("langfuse_span_scope", "LITELLM_OTEL_LANGFUSE_SPAN_SCOPE"),
|
||||
description=(
|
||||
"``llm_only`` keeps just the model-call spans on the operator's own Langfuse "
|
||||
"exporter (the spec whose owner is ``langfuse_otel``). Other exporters and "
|
||||
"key/team destinations are not affected."
|
||||
),
|
||||
)
|
||||
|
||||
# ----- explicit multi-destination / vocabulary configuration ------------ #
|
||||
|
||||
|
|
@ -245,6 +255,13 @@ class OpenTelemetryV2Config(BaseSettings):
|
|||
return value.lower()
|
||||
return value
|
||||
|
||||
@field_validator("langfuse_span_scope", mode="before")
|
||||
@classmethod
|
||||
def _normalize_langfuse_span_scope(cls, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower()
|
||||
return value
|
||||
|
||||
@field_validator(
|
||||
"baggage_promoted_keys",
|
||||
"baggage_metadata_keys",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ from urllib.parse import quote
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from litellm.types.utils import OtelSpanScope
|
||||
|
||||
|
||||
class OtelDestination(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
|
@ -25,6 +27,10 @@ class OtelDestination(BaseModel):
|
|||
"scheme: Arize's ``https://otlp.arize.com/v1`` is gRPC."
|
||||
),
|
||||
)
|
||||
span_scope: OtelSpanScope = Field(
|
||||
default="full",
|
||||
description="``llm_only`` keeps just the model-call spans; the rest of the request tree is not forwarded.",
|
||||
)
|
||||
|
||||
def header_string(self) -> str:
|
||||
"""Render headers as the ``k=v,k2=v2`` form an ``ExporterSpec`` expects.
|
||||
|
|
@ -37,7 +43,12 @@ class OtelDestination(BaseModel):
|
|||
return ",".join(f"{key}={quote(value, safe='')}" for key, value in self.headers.items())
|
||||
|
||||
def cache_key(self) -> tuple[str, tuple[tuple[str, str], ...], tuple[tuple[str, str], ...], str | None]:
|
||||
"""Identity for processor reuse, so one destination means one exporter."""
|
||||
"""Identity for processor reuse, so one destination means one exporter.
|
||||
|
||||
``span_scope`` is left out on purpose: the scope decides which spans reach the
|
||||
processor, not how the processor exports them, so a full and an ``llm_only``
|
||||
view of the same account share one exporter.
|
||||
"""
|
||||
return (
|
||||
self.endpoint,
|
||||
tuple(sorted(self.headers.items())),
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ from collections.abc import Mapping, Sequence
|
|||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, ClassVar, Final, cast
|
||||
from typing import TYPE_CHECKING, ClassVar, Final, Literal, cast
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm.integrations.otel.model.metadata import RequestContext, RequestIdentity
|
||||
from litellm.integrations.otel.model.semconv import (
|
||||
GenAIOperation,
|
||||
|
|
@ -25,6 +27,7 @@ from litellm.integrations.otel.model.utils import (
|
|||
as_float,
|
||||
as_int,
|
||||
as_str,
|
||||
as_str_mapping,
|
||||
as_str_tuple,
|
||||
)
|
||||
|
||||
|
|
@ -424,7 +427,7 @@ class LLMCallSpanData:
|
|||
# plain ``.get`` — no repeated ``isinstance`` guards.
|
||||
raw_response: Final = payload.get("response")
|
||||
response: Final = cast(Mapping[str, object], raw_response if isinstance(raw_response, dict) else {})
|
||||
choices_out: Final = _dicts(response.get("choices"))
|
||||
choices_out: Final = _dicts(response.get("choices")) or _responses_choices(response)
|
||||
# ``finish_reasons`` is metadata, not content, so derive it from
|
||||
# ``choices_out`` before gating. The raw message/choice bodies are only
|
||||
# retained when content capture is enabled (see ``capture_span_content``);
|
||||
|
|
@ -703,6 +706,84 @@ def _finish_reasons(choices: tuple[Mapping[str, object], ...]) -> tuple[str, ...
|
|||
return tuple(r for c in choices if (r := as_str(c.get("finish_reason"))))
|
||||
|
||||
|
||||
class _ToolFunction(TypedDict):
|
||||
name: ReadOnly[str]
|
||||
arguments: ReadOnly[str]
|
||||
|
||||
|
||||
class _ToolCall(TypedDict):
|
||||
id: ReadOnly[str]
|
||||
type: ReadOnly[Literal["function"]]
|
||||
function: ReadOnly[_ToolFunction]
|
||||
|
||||
|
||||
class _AssistantMessage(TypedDict):
|
||||
role: ReadOnly[str]
|
||||
content: ReadOnly[str | None]
|
||||
refusal: ReadOnly[str | None]
|
||||
tool_calls: ReadOnly[tuple[_ToolCall, ...] | None]
|
||||
|
||||
|
||||
class _Choice(TypedDict):
|
||||
message: ReadOnly[_AssistantMessage]
|
||||
finish_reason: ReadOnly[str | None]
|
||||
|
||||
|
||||
_RESPONSES_TOOL_CALL_TYPES: Final = frozenset({"function_call", "custom_tool_call"})
|
||||
|
||||
|
||||
def _responses_choices(response: Mapping[str, object]) -> tuple[_Choice, ...]:
|
||||
"""A Responses API ``output`` folded into one chat-shaped assistant choice."""
|
||||
items: Final = _dicts(response.get("output"))
|
||||
messages: Final = tuple(item for item in items if item.get("type") == "message")
|
||||
parts: Final = tuple(part for item in messages for part in _dicts(item.get("content")))
|
||||
tool_calls: Final = tuple(
|
||||
_responses_tool_call(item) for item in items if item.get("type") in _RESPONSES_TOOL_CALL_TYPES
|
||||
)
|
||||
if not messages and not tool_calls:
|
||||
return ()
|
||||
message: Final[_AssistantMessage] = {
|
||||
"role": next((role for item in messages if (role := as_str(item.get("role")))), "assistant"),
|
||||
"content": _responses_parts_text(parts, "output_text", "text"),
|
||||
"refusal": _responses_parts_text(parts, "refusal", "refusal"),
|
||||
"tool_calls": tool_calls or None,
|
||||
}
|
||||
choice: Final[_Choice] = {"message": message, "finish_reason": _responses_finish_reason(response, bool(tool_calls))}
|
||||
return (choice,)
|
||||
|
||||
|
||||
def _responses_parts_text(parts: tuple[Mapping[str, object], ...], part_type: str, field: str) -> str | None:
|
||||
texts: Final = tuple(
|
||||
text for part in parts if part.get("type") == part_type if (text := as_str(part.get(field))) is not None
|
||||
)
|
||||
return "".join(texts) if texts else None
|
||||
|
||||
|
||||
def _responses_tool_call(item: Mapping[str, object]) -> _ToolCall:
|
||||
custom: Final = item.get("type") == "custom_tool_call"
|
||||
function: Final[_ToolFunction] = {
|
||||
"name": as_str(item.get("name")) or "",
|
||||
"arguments": as_str(item.get("input" if custom else "arguments")) or "",
|
||||
}
|
||||
tool_call: Final[_ToolCall] = {
|
||||
"id": as_str(item.get("call_id")) or as_str(item.get("id")) or "",
|
||||
"type": "function",
|
||||
"function": function,
|
||||
}
|
||||
return tool_call
|
||||
|
||||
|
||||
def _responses_finish_reason(response: Mapping[str, object], has_tool_calls: bool) -> str | None:
|
||||
status: Final = as_str(response.get("status"))
|
||||
if status == "completed":
|
||||
return "tool_calls" if has_tool_calls else "stop"
|
||||
if status != "incomplete":
|
||||
return None
|
||||
details: Final = as_str_mapping(response.get("incomplete_details"))
|
||||
reason: Final = details.get("reason") if details is not None else None
|
||||
return "content_filter" if reason == "content_filter" else "length"
|
||||
|
||||
|
||||
def _parse_error(payload: StandardLoggingPayload) -> SpanError | None:
|
||||
"""A ``SpanError`` for a failed request, or ``None`` on success."""
|
||||
if payload.get("status") != "failure":
|
||||
|
|
|
|||
|
|
@ -35,13 +35,14 @@ from opentelemetry.sdk.trace.export import (
|
|||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
|
||||
InMemorySpanExporter,
|
||||
)
|
||||
from opentelemetry.trace import Span, SpanKind, Status, Tracer
|
||||
from opentelemetry.trace import Span, SpanContext, SpanKind, Status, Tracer
|
||||
from opentelemetry.util.re import parse_env_headers
|
||||
from opentelemetry.util.types import Attributes, AttributeValue
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm._version import version as litellm_version
|
||||
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.mappers.langfuse import LANGFUSE_TRACE_NAME
|
||||
from litellm.integrations.otel.model.config import ExporterOwner, ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.model.semconv import (
|
||||
DB,
|
||||
MCP,
|
||||
|
|
@ -63,6 +64,7 @@ if TYPE_CHECKING:
|
|||
from opentelemetry.sdk.metrics.export import MetricReader
|
||||
|
||||
from litellm.integrations.otel.model.destination import OtelDestination
|
||||
from litellm.types.utils import OtelSpanScope
|
||||
|
||||
_SPAN_KIND_BY_ROLE_KIND: Final[dict[LiteLLMSpanKind, SpanKind]] = {
|
||||
LiteLLMSpanKind.SERVER: SpanKind.SERVER,
|
||||
|
|
@ -379,8 +381,8 @@ _URL_KEYS: Final = frozenset({"http.url", "http.target", "url.full"})
|
|||
_URL_QUERY_KEY: Final = "url.query"
|
||||
|
||||
|
||||
class _TenantSpanView(ReadableSpan):
|
||||
"""A ``ReadableSpan`` view for one destination, leaving the operator's own span alone."""
|
||||
class _SpanView(ReadableSpan):
|
||||
"""A ``ReadableSpan`` view for one exporter, leaving the span every other exporter sees alone."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -389,11 +391,12 @@ class _TenantSpanView(ReadableSpan):
|
|||
attributes: Attributes,
|
||||
events: Sequence[Event],
|
||||
status: Status,
|
||||
parent: SpanContext | None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
name=inner.name,
|
||||
context=inner.context,
|
||||
parent=inner.parent,
|
||||
parent=parent,
|
||||
resource=resource,
|
||||
attributes=attributes,
|
||||
events=events,
|
||||
|
|
@ -414,6 +417,39 @@ def _is_tenant_owned_span(attributes: Mapping[str, AttributeValue]) -> bool:
|
|||
return any(key in attributes for key in _TENANT_OWNED_KEYS)
|
||||
|
||||
|
||||
def is_llm_call_span(span: ReadableSpan) -> bool:
|
||||
"""Whether ``span`` is the model call itself.
|
||||
|
||||
The GenAI mapper stamps ``gen_ai.operation.name`` on the model call and on the
|
||||
MCP tool call, so the MCP method name tells the two apart. Guardrail, request
|
||||
root, auth and database spans never carry the operation name; ``gen_ai.request.model``
|
||||
would not do, since baggage promotes it onto every child span.
|
||||
"""
|
||||
attributes: Final = span.attributes or _NO_ATTRIBUTES
|
||||
return GenAI.OPERATION_NAME in attributes and MCP.METHOD_NAME not in attributes
|
||||
|
||||
|
||||
def _in_scope(span: ReadableSpan, scope: "OtelSpanScope") -> bool:
|
||||
return scope == "full" or is_llm_call_span(span)
|
||||
|
||||
|
||||
def _scoped(span: ReadableSpan, scope: "OtelSpanScope") -> ReadableSpan:
|
||||
"""Under ``llm_only`` the model call is the only span the exporter gets, so it goes out as the
|
||||
trace's root (its parent is the request span that is held back) and, unless the caller named the
|
||||
trace, its own name doubles as ``langfuse.trace.name`` so Langfuse does not show "Unnamed trace"."""
|
||||
if scope == "full":
|
||||
return span
|
||||
attributes: Final = span.attributes or _NO_ATTRIBUTES
|
||||
named: Final = (
|
||||
attributes
|
||||
if LANGFUSE_TRACE_NAME in attributes
|
||||
else MappingProxyType({**attributes, LANGFUSE_TRACE_NAME: span.name})
|
||||
)
|
||||
if span.parent is None and named is attributes:
|
||||
return span
|
||||
return _SpanView(span, span.resource, named, span.events, span.status, parent=None)
|
||||
|
||||
|
||||
def _guardrail_unreachable(attributes: Mapping[str, AttributeValue]) -> bool:
|
||||
return attributes.get(LiteLLM.GUARDRAIL_STATUS) in _GUARDRAIL_UNREACHABLE_STATUSES
|
||||
|
||||
|
|
@ -484,7 +520,7 @@ def _for_destination(span: ReadableSpan, destination: "OtelDestination") -> Read
|
|||
return span
|
||||
resource: Final = span.resource.merge(Resource(extra)) if extra else span.resource
|
||||
status: Final = span.status if owned else Status(span.status.status_code)
|
||||
return _TenantSpanView(span, resource, kept, events, status)
|
||||
return _SpanView(span, resource, kept, events, status, parent=span.parent)
|
||||
|
||||
|
||||
class TenantFanOutSpanProcessor(SpanProcessor):
|
||||
|
|
@ -507,7 +543,7 @@ class TenantFanOutSpanProcessor(SpanProcessor):
|
|||
self,
|
||||
processor_factory: 'Callable[["OtelDestination"], SpanProcessor | None] | None' = None,
|
||||
shutdown_drain_seconds: float = _SHUTDOWN_DRAIN_SECONDS,
|
||||
operator_sinks: frozenset[_SinkKey] = frozenset(),
|
||||
operator_sinks: 'Mapping[_SinkKey, "OtelSpanScope"]' = MappingProxyType({}),
|
||||
pending_drains: int = _MAX_PENDING_DRAINS,
|
||||
drain_pool: _DrainPool | None = None,
|
||||
) -> None:
|
||||
|
|
@ -527,29 +563,36 @@ class TenantFanOutSpanProcessor(SpanProcessor):
|
|||
def on_end(self, span: ReadableSpan) -> None:
|
||||
suppressed: Final = suppressed_backends()
|
||||
for destination in request_destinations():
|
||||
if self._operator_already_writes(destination, suppressed):
|
||||
if self._operator_already_writes(span, destination, suppressed) or not _in_scope(
|
||||
span, destination.span_scope
|
||||
):
|
||||
continue
|
||||
processor = self._acquire(destination) # rebind-ok: loop variable; pyright forbids Final in a loop
|
||||
if processor is None:
|
||||
continue
|
||||
try:
|
||||
processor.on_end(_for_destination(span, destination))
|
||||
processor.on_end(_scoped(_for_destination(span, destination), destination.span_scope))
|
||||
except Exception as exc: # noqa: BLE001 # one destination's failure must not cost the others their span
|
||||
verbose_logger.debug("OTel V2 fan-out: forwarding to %s failed: %s", destination.endpoint, exc)
|
||||
finally:
|
||||
self._release(processor)
|
||||
|
||||
def _operator_already_writes(self, destination: "OtelDestination", suppressed: frozenset[str]) -> bool:
|
||||
def _operator_already_writes(
|
||||
self, span: ReadableSpan, destination: "OtelDestination", suppressed: frozenset[str]
|
||||
) -> bool:
|
||||
"""Whether the operator's own exporter is sending this span to the same account.
|
||||
|
||||
Only reachable under ``additive``, where nothing is suppressed: a team that
|
||||
names the operator's own project would otherwise have every span written
|
||||
there twice, once by the operator's exporter and once by the fan-out.
|
||||
there twice, once by the operator's exporter and once by the fan-out. The
|
||||
operator's exporter may itself be narrowed to the model calls, in which case
|
||||
the rest of the tree is still the fan-out's to deliver.
|
||||
"""
|
||||
return (
|
||||
destination.callback_name not in suppressed
|
||||
and _sink_key(destination.endpoint, destination.headers) in self._operator_sinks
|
||||
)
|
||||
sink: Final = _sink_key(destination.endpoint, destination.headers)
|
||||
if destination.callback_name in suppressed or sink is None:
|
||||
return False
|
||||
operator_scope: Final = self._operator_sinks.get(sink)
|
||||
return operator_scope is not None and _in_scope(span, operator_scope)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Close every destination processor, once the spans in flight have landed.
|
||||
|
|
@ -753,19 +796,43 @@ class _OverriddenBackendFilter(SpanProcessor):
|
|||
|
||||
Under ``additive`` mode nothing is suppressed, so the wrapper passes every span
|
||||
straight through and the operator keeps its copy.
|
||||
|
||||
``scope`` narrows what the exporter receives independently of that: under
|
||||
``llm_only`` the model-call spans go through as trace roots and the rest of the
|
||||
tree is held back, unless a destination of the request names ``sink``, the account
|
||||
this exporter writes to, with a wider scope: the fan-out then delivers the rest of
|
||||
the tree there and the model call keeps its place in it.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: SpanProcessor, owner: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
inner: SpanProcessor,
|
||||
owner: str | None,
|
||||
scope: "OtelSpanScope" = "full",
|
||||
sink: _SinkKey | None = None,
|
||||
) -> None:
|
||||
self._inner: Final = inner
|
||||
self._owner: Final = owner
|
||||
self._scope: Final = scope
|
||||
self._sink: Final = sink
|
||||
|
||||
def on_start(self, span: SDKSpan, parent_context: Context | None = None) -> None:
|
||||
self._inner.on_start(span, parent_context)
|
||||
|
||||
def on_end(self, span: ReadableSpan) -> None:
|
||||
if self._owner in suppressed_backends():
|
||||
if self._owner in suppressed_backends() or not _in_scope(span, self._scope):
|
||||
return
|
||||
self._inner.on_end(span)
|
||||
self._inner.on_end(_scoped(span, self._account_scope()))
|
||||
|
||||
def _account_scope(self) -> "OtelSpanScope":
|
||||
if self._scope == "full" or self._sink is None:
|
||||
return self._scope
|
||||
shared: Final = tuple(
|
||||
destination.span_scope
|
||||
for destination in request_destinations()
|
||||
if _sink_key(destination.endpoint, destination.headers) == self._sink
|
||||
)
|
||||
return _widest((self._scope, *shared))
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._inner.shutdown()
|
||||
|
|
@ -1040,6 +1107,9 @@ def build_tracer_provider(
|
|||
tenant is a separate job, done once by :func:`attach_tenant_fan_out`. The
|
||||
per-tenant providers this same function builds must leave it off, or they would
|
||||
filter out the very spans they exist to carry.
|
||||
|
||||
``config.langfuse_span_scope`` narrows the exporter owned by ``langfuse_otel``
|
||||
alone; a collector or any other backend in the same config keeps the full tree.
|
||||
"""
|
||||
provider: Final = TracerProvider(resource=build_resource(config))
|
||||
if baggage_processor is None:
|
||||
|
|
@ -1060,9 +1130,13 @@ def build_tracer_provider(
|
|||
exp,
|
||||
(spec.use_simple_processor if spec.use_simple_processor is not None else use_simple_processor),
|
||||
)
|
||||
owner = spec.owner.value if spec.owner is not None else None
|
||||
owner = spec.owner.value if tenant_overrides and spec.owner is not None else None
|
||||
scope = _operator_scope(config, spec)
|
||||
sink = _sink_key(spec.endpoint, parse_headers(spec.headers)) if _exports_to_the_wire(spec) else None
|
||||
provider.add_span_processor(
|
||||
_OverriddenBackendFilter(processor, owner) if tenant_overrides and owner is not None else processor
|
||||
_OverriddenBackendFilter(processor, owner, scope, sink)
|
||||
if owner is not None or scope != "full"
|
||||
else processor
|
||||
)
|
||||
return provider
|
||||
|
||||
|
|
@ -1084,7 +1158,7 @@ def attach_tenant_fan_out(provider: TracerProvider, *configs: OpenTelemetryV2Con
|
|||
with _FAN_OUT_ATTACH_LOCK:
|
||||
if any(isinstance(processor, TenantFanOutSpanProcessor) for processor in _attached_processors(provider)):
|
||||
return
|
||||
provider.add_span_processor(TenantFanOutSpanProcessor(operator_sinks=operator_sink_keys(*configs)))
|
||||
provider.add_span_processor(TenantFanOutSpanProcessor(operator_sinks=operator_sink_scopes(*configs)))
|
||||
|
||||
|
||||
def deliverable_destinations(
|
||||
|
|
@ -1109,7 +1183,7 @@ def deliverable_destinations(
|
|||
return fan_out.deliverable(destinations) if fan_out is not None else ()
|
||||
|
||||
|
||||
def operator_sink_keys(*configs: OpenTelemetryV2Config) -> frozenset[_SinkKey]:
|
||||
def operator_sink_scopes(*configs: OpenTelemetryV2Config) -> 'Mapping[_SinkKey, "OtelSpanScope"]':
|
||||
"""The accounts the operator's own exporters write to, in destination terms.
|
||||
|
||||
Every v2 logger's config counts, since each logger exports through its own
|
||||
|
|
@ -1118,12 +1192,21 @@ def operator_sink_keys(*configs: OpenTelemetryV2Config) -> frozenset[_SinkKey]:
|
|||
and so is one that never reaches the wire: a console kind ignores the endpoint,
|
||||
and a header-gated spec with no credentials is skipped when the provider is built.
|
||||
"""
|
||||
return frozenset(
|
||||
key
|
||||
scoped: Final[tuple[tuple[_SinkKey, OtelSpanScope], ...]] = tuple(
|
||||
(key, _operator_scope(config, spec))
|
||||
for config in configs
|
||||
for spec in config.exporters
|
||||
if _exports_to_the_wire(spec) and (key := _sink_key(spec.endpoint, parse_headers(spec.headers))) is not None
|
||||
)
|
||||
return MappingProxyType({key: _widest(scope for other, scope in scoped if other == key) for key, _ in scoped})
|
||||
|
||||
|
||||
def _operator_scope(config: OpenTelemetryV2Config, spec: ExporterSpec) -> "OtelSpanScope":
|
||||
return config.langfuse_span_scope if spec.owner is ExporterOwner.LANGFUSE_OTEL else "full"
|
||||
|
||||
|
||||
def _widest(scopes: "Iterable[OtelSpanScope]") -> "OtelSpanScope":
|
||||
return "full" if any(scope == "full" for scope in scopes) else "llm_only"
|
||||
|
||||
|
||||
def _exports_to_the_wire(spec: ExporterSpec) -> bool:
|
||||
|
|
|
|||
|
|
@ -374,10 +374,8 @@ class TenantTracerCache:
|
|||
self._routed_exporter(spec, credential_headers, project_headers, endpoint)
|
||||
for spec in self._config.exporters
|
||||
]
|
||||
update: Final = (
|
||||
{"exporters": exporters} if service_name is None else {"exporters": exporters, "service_name": service_name}
|
||||
)
|
||||
return self._config.model_copy(update=update)
|
||||
routed: Final = self._config.model_copy(update={"exporters": exporters, "langfuse_span_scope": "full"})
|
||||
return routed if service_name is None else routed.model_copy(update={"service_name": service_name})
|
||||
|
||||
def _routed_exporter(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import litellm
|
|||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.otel.model.destination import OtelDestination
|
||||
from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
from litellm.types.utils import OtelSpanScope, StandardCallbackDynamicParams
|
||||
|
||||
#: An endpoint plus the OTLP transport to reach it with, or ``None`` when the backend
|
||||
#: names no destination. The transport is ``None`` where the backend has only one.
|
||||
|
|
@ -111,6 +111,12 @@ _REQUIRED_HEADERS_BY_CALLBACK: Final[Mapping[str, frozenset[str]]] = MappingProx
|
|||
_NO_ATTRS: Final[Mapping[str, str]] = MappingProxyType({})
|
||||
|
||||
|
||||
def _span_scope(callback_name: str, params: StandardCallbackDynamicParams) -> OtelSpanScope:
|
||||
if callback_name != "langfuse_otel":
|
||||
return "full"
|
||||
return params.get("langfuse_span_scope") or "full"
|
||||
|
||||
|
||||
def destination_capable_backends() -> frozenset[str]:
|
||||
"""Backends a key or team can point at its own account."""
|
||||
from litellm.integrations.otel.presets import DYNAMIC_HEADERS_BY_CALLBACK
|
||||
|
|
@ -149,4 +155,5 @@ def destination_for(
|
|||
resource_attributes=MappingProxyType({"service.name": service_name}) if service_name else _NO_ATTRS,
|
||||
callback_name=callback_name,
|
||||
protocol=protocol,
|
||||
span_scope=_span_scope(callback_name, params),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import re
|
|||
from collections.abc import Iterator, Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
from litellm.types.utils import TRUSTED_CALLBACK_VARS_FIELD, StandardCallbackDynamicParams
|
||||
from litellm.types.utils import OTEL_SPAN_SCOPES, TRUSTED_CALLBACK_VARS_FIELD, StandardCallbackDynamicParams
|
||||
|
||||
_CLIENT_CALLBACK_METADATA_SLOTS: Final[tuple[str, ...]] = ("litellm_metadata", "metadata")
|
||||
|
||||
|
|
@ -62,6 +62,11 @@ def validate_langfuse_environment_value(value: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
def validate_langfuse_span_scope_value(value: str) -> None:
|
||||
if value not in OTEL_SPAN_SCOPES:
|
||||
raise ValueError(f"Invalid langfuse_span_scope {value!r}: must be one of {sorted(OTEL_SPAN_SCOPES)}")
|
||||
|
||||
|
||||
# Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict
|
||||
_supported_callback_params: Final[tuple[str, ...]] = (
|
||||
"langfuse_public_key",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ if TYPE_CHECKING:
|
|||
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import BilledTokenRates
|
||||
from litellm.llms.base_llm.passthrough.transformation import PassthroughStreamCollector
|
||||
from litellm.proxy.hooks.autorouter_baseline_cache import BaselineCacheContext, CapturedBaselineObservation
|
||||
try:
|
||||
from litellm_enterprise.enterprise_callbacks.callback_controls import (
|
||||
EnterpriseCallbackControls,
|
||||
|
|
@ -501,6 +502,8 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
litellm_request_debug: bool = False
|
||||
streamed_anthropic_message_id: str | None = None
|
||||
classifier_input: Mapping[str, JsonValue] | None = None
|
||||
baseline_cache_context: "BaselineCacheContext | None" = None
|
||||
baseline_observation: "CapturedBaselineObservation | None" = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -508,7 +511,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
messages,
|
||||
stream,
|
||||
call_type,
|
||||
start_time,
|
||||
start_time: datetime.datetime,
|
||||
litellm_call_id: str,
|
||||
function_id: str,
|
||||
litellm_trace_id: str | None = None,
|
||||
|
|
@ -2181,6 +2184,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
logging_result,
|
||||
start_time,
|
||||
end_time,
|
||||
build_logging_payload: bool = True,
|
||||
):
|
||||
"""Resolve hidden params, compute response cost, and emit the standard logging payload."""
|
||||
hidden_params: Final = getattr(logging_result, "_hidden_params", {})
|
||||
|
|
@ -2205,6 +2209,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
else:
|
||||
self.model_call_details["response_cost"] = self._response_cost_calculator(result=logging_result)
|
||||
|
||||
if not build_logging_payload:
|
||||
return
|
||||
|
||||
self.model_call_details["standard_logging_object"] = self._build_standard_logging_payload(
|
||||
logging_result, start_time, end_time
|
||||
)
|
||||
|
|
@ -2215,6 +2222,19 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
if standard_logging_payload is not None:
|
||||
emit_standard_logging_payload(standard_logging_payload)
|
||||
|
||||
async def _prepare_baseline_cache_estimate(self, response_obj: object) -> None:
|
||||
if self.baseline_cache_context is None:
|
||||
return
|
||||
from litellm.proxy.hooks.autorouter_baseline_cache import finalize_baseline_cache
|
||||
|
||||
await finalize_baseline_cache(self, response_obj)
|
||||
|
||||
async def invalidate_baseline_cache_estimate(self, reason: str, *, completed: bool = False) -> None:
|
||||
"""Invalidate uncertain attempts; retire the reservation at logical completion."""
|
||||
from litellm.proxy.hooks.autorouter_baseline_cache import invalidate_baseline_cache
|
||||
|
||||
await invalidate_baseline_cache(self, reason, completed=completed)
|
||||
|
||||
def _build_standard_logging_payload(
|
||||
self, init_response_obj: object, start_time: Any, end_time: Any
|
||||
) -> StandardLoggingPayload | None:
|
||||
|
|
@ -2266,6 +2286,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
end_time=None,
|
||||
cache_hit=None,
|
||||
standard_logging_object: StandardLoggingPayload | None = None,
|
||||
build_logging_payload: bool = True,
|
||||
):
|
||||
try:
|
||||
if start_time is None:
|
||||
|
|
@ -2303,6 +2324,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
logging_result=logging_result,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
build_logging_payload=build_logging_payload,
|
||||
)
|
||||
elif standard_logging_object is not None:
|
||||
self.model_call_details["standard_logging_object"] = standard_logging_object
|
||||
|
|
@ -3051,8 +3073,17 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
result=result,
|
||||
cache_hit=cache_hit,
|
||||
standard_logging_object=kwargs.get("standard_logging_object", None),
|
||||
build_logging_payload=self.baseline_cache_context is None,
|
||||
)
|
||||
|
||||
if self.stream is not True and self.baseline_cache_context is not None:
|
||||
await self._prepare_baseline_cache_estimate(result)
|
||||
self.model_call_details["standard_logging_object"] = self._build_standard_logging_payload(
|
||||
result, start_time, end_time
|
||||
)
|
||||
if (prepared_payload := self.model_call_details.get("standard_logging_object")) is not None:
|
||||
emit_standard_logging_payload(prepared_payload)
|
||||
|
||||
## BUILD COMPLETE STREAMED RESPONSE
|
||||
if "async_complete_streaming_response" in self.model_call_details:
|
||||
return # break out of this.
|
||||
|
|
@ -3097,6 +3128,8 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
|
||||
self._merge_hidden_params_from_response_into_metadata(complete_streaming_response)
|
||||
|
||||
await self._prepare_baseline_cache_estimate(complete_streaming_response)
|
||||
|
||||
## STANDARDIZED LOGGING PAYLOAD
|
||||
try:
|
||||
self.model_call_details["standard_logging_object"] = self._build_standard_logging_payload(
|
||||
|
|
@ -3125,6 +3158,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
# Only build standard_logging_object if not already built by
|
||||
# _success_handler_helper_fn
|
||||
if self.model_call_details.get("standard_logging_object") is None:
|
||||
await self._prepare_baseline_cache_estimate(result)
|
||||
## STANDARDIZED LOGGING PAYLOAD
|
||||
self.model_call_details["standard_logging_object"] = self._build_standard_logging_payload(
|
||||
result, start_time, end_time
|
||||
|
|
@ -3631,6 +3665,8 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
"""
|
||||
Implementing async callbacks, to handle asyncio event loop issues when custom integrations need to use async functions.
|
||||
"""
|
||||
if self.baseline_cache_context is not None:
|
||||
await self.invalidate_baseline_cache_estimate("failed_request")
|
||||
await self.special_failure_handlers(exception=exception)
|
||||
if not self.should_run_logging(event_type="async_failure"): # prevent double logging
|
||||
return
|
||||
|
|
@ -5528,6 +5564,10 @@ class StandardLoggingPayloadSetup:
|
|||
for key in metadata.keys() & _STANDARD_LOGGING_METADATA_KEYS:
|
||||
clean_metadata[key] = metadata[key]
|
||||
|
||||
recorded_guardrails: Final = metadata.get("applied_guardrails")
|
||||
if applied_guardrails and isinstance(recorded_guardrails, list):
|
||||
clean_metadata["applied_guardrails"] = list(dict.fromkeys([*applied_guardrails, *recorded_guardrails]))
|
||||
|
||||
user_api_key: Final = metadata.get("user_api_key")
|
||||
if user_api_key and isinstance(user_api_key, str) and is_valid_sha256_hash(user_api_key):
|
||||
clean_metadata["user_api_key_hash"] = user_api_key
|
||||
|
|
@ -6149,6 +6189,8 @@ def _autorouter_savings_for_payload(
|
|||
model_id: str | None,
|
||||
usage_object: Mapping[str, object] | None,
|
||||
cost_breakdown: Mapping[str, object] | None,
|
||||
baseline_usage: Usage | None = None,
|
||||
baseline_provenance: Literal["observed_initial", "modeled"] | None = None,
|
||||
) -> float | None:
|
||||
"""The auto-router savings figure for the payload, or ``None`` when there is none.
|
||||
|
||||
|
|
@ -6167,6 +6209,8 @@ def _autorouter_savings_for_payload(
|
|||
model_id=model_id,
|
||||
usage_object=usage_object,
|
||||
cost_breakdown=cost_breakdown,
|
||||
baseline_usage=baseline_usage,
|
||||
baseline_provenance=baseline_provenance,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # a savings figure must never fail request logging
|
||||
verbose_logger.debug("autorouter savings skipped on logging payload: %s", e)
|
||||
|
|
@ -6343,13 +6387,18 @@ def get_standard_logging_object_payload(
|
|||
model_name = response_model_name
|
||||
|
||||
request_cost_breakdown: Final = cost_breakdown_with_guardrail(logging_obj.cost_breakdown, guardrail_cost)
|
||||
autorouter_savings: Final = _autorouter_savings_for_payload(
|
||||
request_metadata=metadata,
|
||||
model=model_name,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
model_id=_model_id,
|
||||
usage_object=usage_dict,
|
||||
cost_breakdown=request_cost_breakdown,
|
||||
captured_baseline: Final = logging_obj.baseline_observation
|
||||
autorouter_savings: Final = (
|
||||
None
|
||||
if status != "success" or cache_hit or logging_obj.baseline_cache_context is not None
|
||||
else _autorouter_savings_for_payload(
|
||||
request_metadata=metadata,
|
||||
model=model_name,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
model_id=_model_id,
|
||||
usage_object=usage_dict,
|
||||
cost_breakdown=request_cost_breakdown,
|
||||
)
|
||||
)
|
||||
|
||||
payload: Final[StandardLoggingPayload] = StandardLoggingPayload(
|
||||
|
|
@ -6396,6 +6445,26 @@ def get_standard_logging_object_payload(
|
|||
response_cost=response_cost,
|
||||
cost_breakdown=request_cost_breakdown,
|
||||
autorouter_savings=autorouter_savings,
|
||||
autorouter_savings_estimate=(
|
||||
{
|
||||
"version": 3,
|
||||
"status": "unknown",
|
||||
"reason": "pending_projection",
|
||||
} # mutable-ok: spend-log JSON serialization requires plain mappings
|
||||
if captured_baseline is not None
|
||||
else (
|
||||
{ # mutable-ok: spend-log JSON serialization requires plain mappings
|
||||
"version": 1,
|
||||
"status": "estimated" if autorouter_savings is not None else "unknown",
|
||||
"reason": "uncached_usage" if autorouter_savings is not None else "baseline_unavailable",
|
||||
}
|
||||
if metadata.get("routing_decision")
|
||||
else None
|
||||
)
|
||||
),
|
||||
autorouter_baseline_observation=(
|
||||
captured_baseline.model_dump_json() if captured_baseline is not None else None
|
||||
),
|
||||
total_tokens=usage_dict.get("total_tokens", 0),
|
||||
prompt_tokens=usage_dict.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_dict.get("completion_tokens", 0),
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ def _redact_responses_api_output(output_items):
|
|||
for content_part in output_item.content:
|
||||
if getattr(content_part, "text", None) is not None:
|
||||
content_part.text = REDACTED_BY_LITELLM
|
||||
if getattr(content_part, "refusal", None) is not None:
|
||||
content_part.refusal = REDACTED_BY_LITELLM
|
||||
|
||||
# Redact reasoning items in output array
|
||||
if hasattr(output_item, "type") and output_item.type == "reasoning":
|
||||
|
|
@ -138,6 +140,8 @@ def _redact_responses_api_output(output_items):
|
|||
|
||||
if hasattr(output_item, "type") and output_item.type == "function_call" and hasattr(output_item, "arguments"):
|
||||
output_item.arguments = REDACTED_BY_LITELLM
|
||||
if hasattr(output_item, "type") and output_item.type == "custom_tool_call" and hasattr(output_item, "input"):
|
||||
output_item.input = REDACTED_BY_LITELLM
|
||||
|
||||
|
||||
def _redact_responses_api_output_dict(output_items, redacted_str: str):
|
||||
|
|
@ -153,6 +157,8 @@ def _redact_responses_api_output_dict(output_items, redacted_str: str):
|
|||
for content_item in output_item["content"]:
|
||||
if isinstance(content_item, dict) and content_item.get("text") is not None:
|
||||
content_item["text"] = redacted_str
|
||||
if isinstance(content_item, dict) and content_item.get("refusal") is not None:
|
||||
content_item["refusal"] = redacted_str
|
||||
|
||||
if output_item.get("type") == "reasoning" and isinstance(output_item.get("summary"), list):
|
||||
for summary_item in output_item["summary"]:
|
||||
|
|
@ -161,6 +167,8 @@ def _redact_responses_api_output_dict(output_items, redacted_str: str):
|
|||
|
||||
if output_item.get("type") == "function_call" and "arguments" in output_item:
|
||||
output_item["arguments"] = redacted_str
|
||||
if output_item.get("type") == "custom_tool_call" and "input" in output_item:
|
||||
output_item["input"] = redacted_str
|
||||
|
||||
|
||||
def redacted_standard_logging_payload(payload: Mapping[str, object]) -> Mapping[str, object]:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ Calling + translation logic for anthropic's `/v1/messages` endpoint
|
|||
|
||||
import copy
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Union, cast
|
||||
|
||||
import httpx
|
||||
|
|
@ -31,7 +32,6 @@ from litellm.types.llms.anthropic import (
|
|||
ContentBlockStop,
|
||||
MessageBlockDelta,
|
||||
MessageStartBlock,
|
||||
UsageDelta,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
ChatCompletionRedactedThinkingBlock,
|
||||
|
|
@ -557,6 +557,7 @@ class ModelResponseIterator:
|
|||
self.tool_index = -1
|
||||
self.json_mode = json_mode
|
||||
self.speed = speed
|
||||
self._cumulative_usage: Mapping[str, object] = MappingProxyType({})
|
||||
# rewritten-name -> caller's original. Built per-request from the
|
||||
# forward map in AnthropicConfig._build_request_tool_name_maps; only
|
||||
# contains entries we actually rewrote, so a tool legitimately named
|
||||
|
|
@ -631,10 +632,12 @@ class ModelResponseIterator:
|
|||
return True
|
||||
return False
|
||||
|
||||
def _handle_usage(self, anthropic_usage_chunk: dict | UsageDelta) -> Usage:
|
||||
def _handle_usage(self, anthropic_usage_chunk: Mapping[str, object]) -> Usage:
|
||||
# message_delta usage is cumulative but may omit fields reported at message_start.
|
||||
self._cumulative_usage = MappingProxyType({**self._cumulative_usage, **anthropic_usage_chunk})
|
||||
reasoning_content: Final = "".join(self.reasoning_content_chunks) if self.reasoning_content_chunks else None
|
||||
usage: Final = AnthropicConfig().calculate_usage(
|
||||
usage_object=cast(dict, anthropic_usage_chunk),
|
||||
usage_object=self._cumulative_usage,
|
||||
reasoning_content=reasoning_content,
|
||||
speed=self.speed,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ Anthropic CountTokens API handler.
|
|||
Uses httpx for HTTP requests instead of the Anthropic SDK.
|
||||
"""
|
||||
|
||||
from typing import Any, Final
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -16,6 +18,8 @@ from litellm.llms.anthropic.count_tokens.transformation import (
|
|||
)
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
|
||||
_COUNT_RESPONSE: Final = TypeAdapter(dict[str, JsonValue])
|
||||
|
||||
|
||||
class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
|
||||
"""
|
||||
|
|
@ -27,13 +31,14 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
|
|||
async def handle_count_tokens_request(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
messages: list[dict[str, JsonValue]],
|
||||
api_key: str,
|
||||
api_base: str | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
system: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tools: list[dict[str, JsonValue]] | None = None,
|
||||
system: JsonValue = None,
|
||||
optional_params: Mapping[str, JsonValue] | None = None,
|
||||
) -> dict[str, JsonValue]:
|
||||
"""
|
||||
Handle a CountTokens request using httpx.
|
||||
|
||||
|
|
@ -52,7 +57,7 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
|
|||
"""
|
||||
try:
|
||||
# Validate the request
|
||||
self.validate_request(model, messages)
|
||||
self.validate_request(model, messages, system=system, tools=tools)
|
||||
|
||||
verbose_logger.debug("Processing Anthropic CountTokens request for model: %s", model)
|
||||
|
||||
|
|
@ -62,6 +67,7 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
|
|||
messages=messages,
|
||||
tools=tools,
|
||||
system=system,
|
||||
optional_params=optional_params,
|
||||
)
|
||||
|
||||
verbose_logger.debug("Transformed request: %s", request_body)
|
||||
|
|
@ -97,7 +103,7 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
|
|||
message=error_text,
|
||||
)
|
||||
|
||||
anthropic_response: Final = response.json()
|
||||
anthropic_response: Final = _COUNT_RESPONSE.validate_json(response.content)
|
||||
|
||||
verbose_logger.debug("Anthropic response: %s", anthropic_response)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,17 @@ Anthropic CountTokens API transformation logic.
|
|||
This module handles the transformation of requests to Anthropic's CountTokens API format.
|
||||
"""
|
||||
|
||||
from typing import Any, Final
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
from litellm.constants import ANTHROPIC_TOKEN_COUNTING_BETA_VERSION
|
||||
|
||||
_COUNT_REQUEST: Final = TypeAdapter(dict[str, JsonValue])
|
||||
COUNT_TOKEN_OPTION_NAMES: Final = ("thinking", "tool_choice", "output_config")
|
||||
|
||||
|
||||
class AnthropicCountTokensConfig:
|
||||
"""
|
||||
|
|
@ -31,27 +38,31 @@ class AnthropicCountTokensConfig:
|
|||
def transform_request_to_count_tokens(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
system: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
messages: list[dict[str, JsonValue]],
|
||||
tools: list[dict[str, JsonValue]] | None = None,
|
||||
system: JsonValue = None,
|
||||
optional_params: Mapping[str, JsonValue] | None = None,
|
||||
) -> dict[str, JsonValue]: # mutable-ok: provider transport requires JSON dictionaries
|
||||
"""
|
||||
Transform request to Anthropic CountTokens format.
|
||||
|
||||
Includes optional system and tools fields for accurate token counting.
|
||||
"""
|
||||
request: Final[dict[str, Any]] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
if system is not None:
|
||||
request["system"] = system
|
||||
|
||||
if tools is not None:
|
||||
request["tools"] = tools
|
||||
|
||||
return request
|
||||
options: Final[Mapping[str, JsonValue]] = optional_params or MappingProxyType({})
|
||||
return _COUNT_REQUEST.validate_python(
|
||||
MappingProxyType(
|
||||
{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
**MappingProxyType(
|
||||
{key: value for key, value in (("system", system), ("tools", tools)) if value is not None}
|
||||
),
|
||||
**MappingProxyType(
|
||||
{key: value for key, value in options.items() if key in COUNT_TOKEN_OPTION_NAMES}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def get_required_headers(self, api_key: str) -> dict[str, str]:
|
||||
"""
|
||||
|
|
@ -76,7 +87,14 @@ class AnthropicCountTokensConfig:
|
|||
headers, _ = optionally_handle_anthropic_oauth(headers=headers, api_key=api_key)
|
||||
return headers
|
||||
|
||||
def validate_request(self, model: str, messages: list[dict[str, Any]]) -> None:
|
||||
def validate_request(
|
||||
self,
|
||||
model: str,
|
||||
messages: Sequence[Mapping[str, JsonValue]],
|
||||
*,
|
||||
system: JsonValue = None,
|
||||
tools: list[dict[str, JsonValue]] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Validate the incoming count tokens request.
|
||||
|
||||
|
|
@ -90,7 +108,7 @@ class AnthropicCountTokensConfig:
|
|||
if not model:
|
||||
raise ValueError("model parameter is required")
|
||||
|
||||
if not messages:
|
||||
if not messages and not system and not tools:
|
||||
raise ValueError("messages parameter is required")
|
||||
|
||||
if not isinstance(messages, list):
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from itertools import accumulate
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import accumulate, groupby
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Final, Literal, Protocol, TypeAlias
|
||||
|
||||
|
|
@ -14,9 +15,14 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue, StrictInt, TypeAda
|
|||
import litellm
|
||||
from litellm.llms.anthropic.common_utils import AnthropicModelInfo, is_anthropic_oauth_key
|
||||
from litellm.llms.anthropic.count_tokens.handler import AnthropicCountTokensHandler
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import DEFAULT_ANTHROPIC_API_VERSION
|
||||
from litellm.llms.anthropic.count_tokens.transformation import COUNT_TOKEN_OPTION_NAMES
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
|
||||
DEFAULT_ANTHROPIC_API_VERSION,
|
||||
AnthropicMessagesConfig,
|
||||
)
|
||||
from litellm.types.router import LiteLLM_Params
|
||||
from litellm.types.utils import ModelResponse
|
||||
from litellm.utils import supports_thinking_cache_preservation
|
||||
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_HEADERS: Final = TypeAdapter(dict[str, str])
|
||||
|
|
@ -100,10 +106,7 @@ _Block: TypeAlias = Annotated[_Text | _ToolUse | _ToolResult, Field(discriminato
|
|||
|
||||
class _Message(_StrictModel):
|
||||
role: Literal["user", "assistant"]
|
||||
content: str | Annotated[tuple[_Block, ...], Field(strict=False)]
|
||||
|
||||
def blocks(self) -> tuple[_Text | _ToolUse | _ToolResult, ...]:
|
||||
return (_Text(type="text", text=self.content),) if isinstance(self.content, str) else tuple(self.content)
|
||||
content: Annotated[str, Field(min_length=1, pattern=r"\S")] | Annotated[tuple[_Block, ...], Field(strict=False)]
|
||||
|
||||
|
||||
class _Tool(_StrictModel):
|
||||
|
|
@ -113,10 +116,7 @@ class _Tool(_StrictModel):
|
|||
type: Literal["custom"] | None = None
|
||||
|
||||
|
||||
class _Request(_StrictModel):
|
||||
messages: tuple[_Message, ...] = Field(min_length=1, strict=False)
|
||||
system: str | Annotated[tuple[_ResultText, ...], Field(strict=False)] | None = None
|
||||
tools: Annotated[tuple[_Tool, ...], Field(strict=False)] | None = None
|
||||
class _RequestOptions(_StrictModel):
|
||||
model: str | None = None
|
||||
max_tokens: int | None = None
|
||||
stream: bool | None = None
|
||||
|
|
@ -127,6 +127,289 @@ class _Request(_StrictModel):
|
|||
metadata: Mapping[str, JsonValue] | None = None
|
||||
|
||||
|
||||
class _Request(_RequestOptions):
|
||||
messages: tuple[_Message, ...] = Field(min_length=1, strict=False)
|
||||
system: str | Annotated[tuple[_ResultText, ...], Field(strict=False)] | None = None
|
||||
tools: Annotated[tuple[_Tool, ...], Field(strict=False)] | None = None
|
||||
|
||||
|
||||
class _Thinking(_StrictModel):
|
||||
type: Literal["thinking"]
|
||||
thinking: str
|
||||
signature: str = Field(min_length=1)
|
||||
|
||||
|
||||
_PlanBlock: TypeAlias = Annotated[_Text | _ToolUse | _ToolResult | _Thinking, Field(discriminator="type")]
|
||||
|
||||
|
||||
class _PlanMessage(_StrictModel):
|
||||
role: Literal["user", "assistant", "system"]
|
||||
content: str | Annotated[tuple[_PlanBlock, ...], Field(strict=False)]
|
||||
|
||||
|
||||
class _PlanTool(_Tool):
|
||||
cache_control: _CacheControl | None = None
|
||||
|
||||
|
||||
class _PlanRequest(_RequestOptions):
|
||||
messages: tuple[_PlanMessage, ...] = Field(min_length=1, strict=False)
|
||||
system: str | Annotated[tuple[_Text, ...], Field(strict=False)] | None = None
|
||||
tools: Annotated[tuple[_PlanTool, ...], Field(strict=False)] | None = None
|
||||
cache_control: _CacheControl | None = None
|
||||
thinking: Mapping[str, JsonValue] | None = None
|
||||
tool_choice: Mapping[str, JsonValue] | None = None
|
||||
output_config: Mapping[str, JsonValue] | None = None
|
||||
speed: Literal["fast", "standard"] | None = None
|
||||
service_tier: Literal["auto", "standard_only"] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CacheBoundary:
|
||||
fingerprint: str
|
||||
prefix_body: Mapping[str, JsonValue] = field(repr=False)
|
||||
ttl_seconds: int
|
||||
lookback_fingerprints: tuple[str, ...]
|
||||
content_fingerprint: str = ""
|
||||
lookback_content_fingerprints: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PromptCachePlan:
|
||||
full_body: Mapping[str, JsonValue] = field(repr=False)
|
||||
breakpoints: tuple[CacheBoundary, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnsupportedCachePlan:
|
||||
reason: Literal[
|
||||
"unsupported_prompt_shape",
|
||||
"conflicting_cache_ttl",
|
||||
"too_many_cache_breakpoints",
|
||||
"invalid_cache_ttl_order",
|
||||
"unsupported_thinking_cache_semantics",
|
||||
"token_count_unavailable",
|
||||
"inconsistent_prefix_token_count",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CountedBreakpoint:
|
||||
fingerprint: str
|
||||
ttl_seconds: int
|
||||
prefix_tokens: int
|
||||
lookback_fingerprints: tuple[str, ...]
|
||||
content_fingerprint: str = ""
|
||||
lookback_content_fingerprints: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CountedPromptCachePlan:
|
||||
total_tokens: int
|
||||
breakpoints: tuple[CountedBreakpoint, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Position:
|
||||
section: Literal["tools", "system", "messages"]
|
||||
message_index: int
|
||||
role: str
|
||||
block: Mapping[str, JsonValue]
|
||||
marker: _CacheControl | None
|
||||
|
||||
|
||||
def _content_blocks(content: JsonValue) -> tuple[Mapping[str, JsonValue], ...]:
|
||||
if isinstance(content, str):
|
||||
return (MappingProxyType({"type": "text", "text": content}),)
|
||||
return tuple(_JSON_OBJECT.validate_python(block) for block in content) if isinstance(content, list) else ()
|
||||
|
||||
|
||||
def _position(
|
||||
section: Literal["tools", "system", "messages"],
|
||||
message_index: int,
|
||||
role: str,
|
||||
block: Mapping[str, JsonValue],
|
||||
) -> _Position:
|
||||
control: Final = block.get("cache_control")
|
||||
return _Position(
|
||||
section,
|
||||
message_index,
|
||||
role,
|
||||
MappingProxyType({key: value for key, value in block.items() if key != "cache_control"}),
|
||||
_CacheControl.model_validate(control) if control is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _positions(body: Mapping[str, JsonValue]) -> tuple[_Position, ...]:
|
||||
tools: Final = body.get("tools")
|
||||
messages: Final = body.get("messages")
|
||||
return (
|
||||
*tuple(
|
||||
_position("tools", -1, "", _JSON_OBJECT.validate_python(tool))
|
||||
for tool in (tools if isinstance(tools, list) else ())
|
||||
),
|
||||
*tuple(_position("system", -1, "", block) for block in _content_blocks(body.get("system"))),
|
||||
*tuple(
|
||||
_position("messages", message_index, str(message.get("role")), block)
|
||||
for message_index, raw_message in enumerate(messages if isinstance(messages, list) else ())
|
||||
for message in (_JSON_OBJECT.validate_python(raw_message),)
|
||||
for block in _content_blocks(message.get("content"))
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _prefix_body(
|
||||
body: Mapping[str, JsonValue],
|
||||
positions: tuple[_Position, ...],
|
||||
last_index: int,
|
||||
) -> Mapping[str, JsonValue]:
|
||||
prefix: Final = positions[: last_index + 1]
|
||||
sections: Final = MappingProxyType(
|
||||
{
|
||||
section: _count_objects(tuple(position.block for position in prefix if position.section == section))
|
||||
for section in ("tools", "system")
|
||||
if any(position.section == section for position in prefix)
|
||||
}
|
||||
)
|
||||
messages: Final = tuple(
|
||||
MappingProxyType(
|
||||
_JSON_OBJECT.validate_python(
|
||||
MappingProxyType(
|
||||
{"role": group[0].role, "content": _count_objects(tuple(position.block for position in group))}
|
||||
)
|
||||
)
|
||||
)
|
||||
for _, values in groupby(
|
||||
(position for position in prefix if position.section == "messages"),
|
||||
key=lambda position: position.message_index,
|
||||
)
|
||||
for group in (tuple(values),)
|
||||
)
|
||||
return MappingProxyType(
|
||||
_JSON_OBJECT.validate_python(
|
||||
MappingProxyType(
|
||||
{
|
||||
**MappingProxyType({key: body[key] for key in COUNT_TOKEN_OPTION_NAMES if key in body}),
|
||||
**sections,
|
||||
"messages": _count_objects(messages),
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _position_group(position: _Position, index: int) -> tuple[str, int, str | int]:
|
||||
block_type: Final = position.block.get("type")
|
||||
return (
|
||||
position.section,
|
||||
position.message_index,
|
||||
block_type if isinstance(block_type, str) and block_type in ("tool_use", "tool_result") else index,
|
||||
)
|
||||
|
||||
|
||||
def _chain_digest(previous: str, current: str) -> str:
|
||||
return _digest((previous, current))
|
||||
|
||||
|
||||
def _cacheable_position(position: _Position) -> bool:
|
||||
block_type: Final = position.block.get("type")
|
||||
if block_type == "thinking":
|
||||
return False
|
||||
text: Final = position.block.get("text")
|
||||
return block_type != "text" or (isinstance(text, str) and bool(text.strip()))
|
||||
|
||||
|
||||
def _entry_fingerprint(fingerprint: str, ttl_seconds: int) -> str:
|
||||
return _digest(("native-cache-prefix-v2", fingerprint, ttl_seconds))
|
||||
|
||||
|
||||
def parse_cache_plan(body: Mapping[str, JsonValue]) -> PromptCachePlan | UnsupportedCachePlan:
|
||||
try:
|
||||
request: Final = _PlanRequest.model_validate(body)
|
||||
positions: Final = _positions(body)
|
||||
except ValidationError:
|
||||
return UnsupportedCachePlan("unsupported_prompt_shape")
|
||||
explicit: Final = tuple(
|
||||
(index, position.marker) for index, position in enumerate(positions) if position.marker is not None
|
||||
)
|
||||
automatic_index: Final = next(
|
||||
(index for index in reversed(range(len(positions))) if _cacheable_position(positions[index])), None
|
||||
)
|
||||
automatic_existing: Final = next((marker for index, marker in explicit if index == automatic_index), None)
|
||||
if (
|
||||
request.cache_control is not None
|
||||
and automatic_existing is not None
|
||||
and automatic_existing != request.cache_control
|
||||
):
|
||||
return UnsupportedCachePlan("conflicting_cache_ttl")
|
||||
automatic: Final = (
|
||||
((automatic_index, request.cache_control),)
|
||||
if (request.cache_control is not None and automatic_index is not None and automatic_existing is None)
|
||||
else ()
|
||||
)
|
||||
markers: Final = tuple(sorted((*explicit, *automatic), key=lambda value: value[0]))
|
||||
if len(markers) > 4:
|
||||
return UnsupportedCachePlan("too_many_cache_breakpoints")
|
||||
ttls: Final = tuple(3600 if marker.ttl == "1h" else 300 for _, marker in markers)
|
||||
if any(first < second for first, second in zip(ttls, ttls[1:])):
|
||||
return UnsupportedCachePlan("invalid_cache_ttl_order")
|
||||
settings: Final = MappingProxyType(
|
||||
{
|
||||
key: body[key]
|
||||
for key in ("thinking", "output_config", "speed")
|
||||
if key in body and not (key == "speed" and body[key] == "standard")
|
||||
}
|
||||
)
|
||||
hashes: Final = tuple(
|
||||
accumulate(
|
||||
(
|
||||
_digest(
|
||||
(
|
||||
position.section,
|
||||
position.message_index,
|
||||
position.role,
|
||||
position.block,
|
||||
body.get("tool_choice") if position.section == "messages" else None,
|
||||
)
|
||||
)
|
||||
for position in positions
|
||||
),
|
||||
_chain_digest,
|
||||
initial=_digest(settings),
|
||||
)
|
||||
)[1:]
|
||||
groups: Final = tuple(
|
||||
tuple(index for index, _ in values)
|
||||
for _, values in groupby(
|
||||
enumerate(positions),
|
||||
key=lambda item: _position_group(item[1], item[0]),
|
||||
)
|
||||
)
|
||||
return PromptCachePlan(
|
||||
full_body=MappingProxyType(dict(body)),
|
||||
breakpoints=tuple(
|
||||
CacheBoundary(
|
||||
fingerprint=_entry_fingerprint(hashes[index], ttl),
|
||||
prefix_body=_prefix_body(body, positions, index),
|
||||
ttl_seconds=ttl,
|
||||
lookback_fingerprints=tuple(
|
||||
_entry_fingerprint(hashes[earlier], ttl)
|
||||
for group in reversed(tuple(group for group in groups if group[0] <= index)[-20:])
|
||||
for earlier in reversed(group)
|
||||
if earlier <= index
|
||||
),
|
||||
content_fingerprint=hashes[index],
|
||||
lookback_content_fingerprints=tuple(
|
||||
hashes[earlier]
|
||||
for group in reversed(tuple(group for group in groups if group[0] <= index)[-20:])
|
||||
for earlier in reversed(group)
|
||||
if earlier <= index
|
||||
),
|
||||
)
|
||||
for (index, _), ttl in zip(markers, ttls)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PromptPrefix:
|
||||
prefix_body: Mapping[str, JsonValue]
|
||||
|
|
@ -137,68 +420,28 @@ class PromptPrefix:
|
|||
|
||||
def _digest(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
json.dumps(value, default=_json_object, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _next_digest(previous: str, boundary: tuple[int, str, Mapping[str, JsonValue]]) -> str:
|
||||
return _digest((previous, boundary))
|
||||
def _json_object(value: object) -> dict[str, JsonValue]: # mutable-ok: JSON serialization requires a dictionary
|
||||
return _JSON_OBJECT.validate_python(value)
|
||||
|
||||
|
||||
def parse_prompt(body: Mapping[str, JsonValue]) -> PromptPrefix | None:
|
||||
try:
|
||||
request: Final = _Request.model_validate(body)
|
||||
blocks: Final = tuple(message.blocks() for message in request.messages)
|
||||
_Request.model_validate(body)
|
||||
except ValidationError:
|
||||
return None
|
||||
markers: Final = tuple(
|
||||
(message_index, block_index, block.cache_control)
|
||||
for message_index, message_blocks in enumerate(blocks)
|
||||
for block_index, block in enumerate(message_blocks)
|
||||
if block.cache_control is not None
|
||||
)
|
||||
if len(markers) != 1:
|
||||
plan: Final = parse_cache_plan(body)
|
||||
if isinstance(plan, UnsupportedCachePlan) or len(plan.breakpoints) != 1:
|
||||
return None
|
||||
message_end, block_end, marker = markers[0]
|
||||
normalized: Final = _JSON_OBJECT.validate_python(request.model_dump(mode="json", exclude_none=True))
|
||||
context: Final = MappingProxyType({key: normalized[key] for key in ("system", "tools") if key in normalized})
|
||||
boundaries: Final = tuple(
|
||||
(
|
||||
message_index,
|
||||
request.messages[message_index].role,
|
||||
_JSON_OBJECT.validate_python(
|
||||
block.model_dump(mode="json", exclude=MappingProxyType({"cache_control": True}), exclude_none=True)
|
||||
),
|
||||
)
|
||||
for message_index, message_blocks in enumerate(blocks[: message_end + 1])
|
||||
for block_index, block in enumerate(message_blocks)
|
||||
if message_index < message_end or block_index <= block_end
|
||||
)
|
||||
hashes: Final = tuple(
|
||||
accumulate(boundaries, _next_digest, initial=_digest((_JSON_OBJECT.validate_python(context), marker.ttl)))
|
||||
)[1:]
|
||||
prefix_messages: Final = tuple(
|
||||
_Message(
|
||||
role=request.messages[message_index].role,
|
||||
content=tuple(
|
||||
block
|
||||
for block_index, block in enumerate(message_blocks)
|
||||
if message_index < message_end or block_index <= block_end
|
||||
),
|
||||
)
|
||||
for message_index, message_blocks in enumerate(blocks[: message_end + 1])
|
||||
)
|
||||
prefix: Final = plan.breakpoints[0]
|
||||
return PromptPrefix(
|
||||
prefix_body=MappingProxyType(
|
||||
_JSON_OBJECT.validate_python(
|
||||
_Request(messages=prefix_messages, system=request.system, tools=request.tools).model_dump(
|
||||
mode="json", exclude_none=True
|
||||
)
|
||||
)
|
||||
),
|
||||
fingerprint=hashes[-1],
|
||||
fingerprints=tuple(reversed(hashes[-20:])),
|
||||
ttl_seconds=3600 if marker.ttl == "1h" else 300,
|
||||
prefix_body=prefix.prefix_body,
|
||||
fingerprint=prefix.fingerprint,
|
||||
fingerprints=prefix.lookback_fingerprints,
|
||||
ttl_seconds=prefix.ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -246,6 +489,9 @@ class _CountBody(BaseModel):
|
|||
messages: Sequence[Mapping[str, JsonValue]]
|
||||
tools: Sequence[Mapping[str, JsonValue]] | None = None
|
||||
system: str | Sequence[Mapping[str, JsonValue]] | None = None
|
||||
thinking: Mapping[str, JsonValue] | None = None
|
||||
tool_choice: Mapping[str, JsonValue] | None = None
|
||||
output_config: Mapping[str, JsonValue] | None = None
|
||||
|
||||
|
||||
class _CountResult(BaseModel):
|
||||
|
|
@ -262,16 +508,36 @@ def _count_objects(
|
|||
return [dict(value) for value in values] # mutable-ok: serialize read-only inputs at the provider API boundary
|
||||
|
||||
|
||||
async def count_prompt_tokens(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None:
|
||||
native: Final = _CountBody.model_validate(body)
|
||||
def _messages_url(model: str, api_key: str, api_base: str | None) -> str:
|
||||
return AnthropicMessagesConfig().get_complete_url( # pyright: ignore[reportUnknownMemberType] # canonical native URL owner takes legacy JSON arguments
|
||||
api_base=api_base,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
optional_params=_JSON_OBJECT.validate_python(MappingProxyType({})),
|
||||
litellm_params=_JSON_OBJECT.validate_python(MappingProxyType({})),
|
||||
)
|
||||
|
||||
|
||||
async def count_prompt_tokens(
|
||||
model: str,
|
||||
api_key: str,
|
||||
body: Mapping[str, JsonValue],
|
||||
api_base: str | None = None,
|
||||
) -> int | None:
|
||||
try:
|
||||
native: Final = _CountBody.model_validate(body)
|
||||
count_url: Final = _messages_url(model, api_key, api_base) + "/count_tokens"
|
||||
result: Final = _CountResult.model_validate(
|
||||
await _counter.handle_count_tokens_request(
|
||||
model=model,
|
||||
messages=_count_objects(native.messages),
|
||||
tools=_count_objects(native.tools) if native.tools is not None else None,
|
||||
system=native.system,
|
||||
system=_JSON_OBJECT.validate_python(MappingProxyType({"system": native.system}))["system"],
|
||||
api_key=api_key,
|
||||
api_base=count_url,
|
||||
optional_params=_JSON_OBJECT.validate_python(
|
||||
MappingProxyType({key: body[key] for key in COUNT_TOKEN_OPTION_NAMES if key in body})
|
||||
),
|
||||
timeout=15.0,
|
||||
)
|
||||
)
|
||||
|
|
@ -280,10 +546,55 @@ async def count_prompt_tokens(model: str, api_key: str, body: Mapping[str, JsonV
|
|||
return result.input_tokens
|
||||
|
||||
|
||||
async def count_cache_plan(
|
||||
model: str,
|
||||
api_key: str,
|
||||
plan: PromptCachePlan,
|
||||
token_counter: TokenCounter = count_prompt_tokens,
|
||||
) -> CountedPromptCachePlan | UnsupportedCachePlan:
|
||||
if any(position.block.get("type") == "thinking" for position in _positions(plan.full_body)):
|
||||
if not supports_thinking_cache_preservation(model, "anthropic"):
|
||||
return UnsupportedCachePlan("unsupported_thinking_cache_semantics")
|
||||
total: Final = await token_counter(model, api_key, plan.full_body)
|
||||
if total is None:
|
||||
return UnsupportedCachePlan("token_count_unavailable")
|
||||
counts: Final = tuple(
|
||||
await asyncio.gather(*(token_counter(model, api_key, marker.prefix_body) for marker in plan.breakpoints))
|
||||
)
|
||||
if any(value is None for value in counts):
|
||||
return UnsupportedCachePlan("token_count_unavailable")
|
||||
known: Final = tuple(value for value in counts if value is not None)
|
||||
if any(value < 0 for value in (total, *known)) or any(
|
||||
first > second for first, second in zip(known, (*known[1:], total))
|
||||
):
|
||||
return UnsupportedCachePlan("inconsistent_prefix_token_count")
|
||||
return CountedPromptCachePlan(
|
||||
total,
|
||||
tuple(
|
||||
CountedBreakpoint(
|
||||
marker.fingerprint,
|
||||
marker.ttl_seconds,
|
||||
count,
|
||||
marker.lookback_fingerprints,
|
||||
marker.content_fingerprint,
|
||||
marker.lookback_content_fingerprints,
|
||||
)
|
||||
for marker, count in zip(plan.breakpoints, known)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NativePredictionTarget:
|
||||
model: str
|
||||
api_key: str
|
||||
api_key: str = field(repr=False)
|
||||
api_base: str | None = None
|
||||
|
||||
|
||||
def supported_baseline_recipient(target: NativePredictionTarget, wire: httpx.Request) -> bool:
|
||||
return wire.headers.get("x-api-key") == target.api_key and wire.url == httpx.URL(
|
||||
_messages_url(target.model, target.api_key, target.api_base)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -297,11 +608,26 @@ class UnsupportedPredictionTarget:
|
|||
|
||||
|
||||
def resolve_prediction_target(params: LiteLLM_Params) -> NativePredictionTarget | UnsupportedPredictionTarget:
|
||||
return _resolve_prediction_target(params, allow_configured_endpoint=False)
|
||||
|
||||
|
||||
def resolve_baseline_prediction_target(params: LiteLLM_Params) -> NativePredictionTarget | UnsupportedPredictionTarget:
|
||||
return _resolve_prediction_target(params, allow_configured_endpoint=True)
|
||||
|
||||
|
||||
def _resolve_prediction_target(
|
||||
params: LiteLLM_Params,
|
||||
*,
|
||||
allow_configured_endpoint: bool,
|
||||
) -> NativePredictionTarget | UnsupportedPredictionTarget:
|
||||
configured_options: Final = frozenset(params.model_dump(exclude_defaults=True, exclude_none=True))
|
||||
if configured_options - _DEPLOYMENT_OPTIONS:
|
||||
return UnsupportedPredictionTarget("unsupported_deployment_configuration")
|
||||
api_base: Final = AnthropicModelInfo.get_api_base(params.api_base)
|
||||
if api_base not in ("https://api.anthropic.com", "https://api.anthropic.com/v1/messages"):
|
||||
if not allow_configured_endpoint and api_base not in (
|
||||
"https://api.anthropic.com",
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
):
|
||||
return UnsupportedPredictionTarget("unsupported_provider_endpoint")
|
||||
try:
|
||||
model, provider, _, _ = litellm.get_llm_provider(
|
||||
|
|
@ -314,7 +640,7 @@ def resolve_prediction_target(params: LiteLLM_Params) -> NativePredictionTarget
|
|||
api_key: Final = AnthropicModelInfo.get_api_key(params.api_key)
|
||||
if api_key is None or not _supported_provider_key(api_key):
|
||||
return UnsupportedPredictionTarget("unsupported_provider_credentials")
|
||||
return NativePredictionTarget(model=model, api_key=api_key)
|
||||
return NativePredictionTarget(model=model, api_key=api_key, api_base=api_base)
|
||||
|
||||
|
||||
def _supported_provider_key(api_key: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -280,10 +280,17 @@ class AzureOpenAIConfig(BaseConfig):
|
|||
ordered_messages: Final = system_messages_first(messages) if litellm.openai_system_messages_first else messages
|
||||
stripped_messages: Final = drop_tool_reference_parts_from_tool_messages(ordered_messages)
|
||||
azure_messages: Final = convert_to_azure_openai_messages(hoist_images_from_tool_messages(stripped_messages))
|
||||
request_params: Final = MappingProxyType(
|
||||
{
|
||||
key: value
|
||||
for key, value in optional_params.items()
|
||||
if key != "tool_choice" or optional_params.get("tools") or optional_params.get("functions")
|
||||
}
|
||||
)
|
||||
return {
|
||||
"model": model,
|
||||
"messages": azure_messages,
|
||||
**optional_params,
|
||||
**request_params,
|
||||
**sanitized_tools_update(optional_params),
|
||||
}
|
||||
|
||||
|
|
|
|||
40
litellm/llms/base_llm/files/litellm_db_storage_backend.py
Normal file
40
litellm/llms/base_llm/files/litellm_db_storage_backend.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from litellm.llms.base_llm.files.storage_backend import BaseFileStorageBackend
|
||||
from litellm.repositories.managed_file_content_repository import ManagedFileContentRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
LITELLM_DB_STORAGE_BACKEND_NAME: Final = "litellm_db"
|
||||
LITELLM_DB_STORAGE_URL_PREFIX: Final = f"{LITELLM_DB_STORAGE_BACKEND_NAME}://"
|
||||
|
||||
|
||||
def storage_url_to_row_id(storage_url: str) -> str:
|
||||
if not storage_url.startswith(LITELLM_DB_STORAGE_URL_PREFIX):
|
||||
raise ValueError(f"Not a {LITELLM_DB_STORAGE_BACKEND_NAME} storage url: {storage_url}")
|
||||
return storage_url.removeprefix(LITELLM_DB_STORAGE_URL_PREFIX)
|
||||
|
||||
|
||||
class LiteLLMDbStorageBackend(BaseFileStorageBackend):
|
||||
def __init__(self, prisma_client: "PrismaClient") -> None:
|
||||
self._contents = ManagedFileContentRepository(prisma_client)
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
file_content: bytes,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
path_prefix: str | None = None,
|
||||
file_naming_strategy: str = "uuid",
|
||||
) -> str:
|
||||
return f"{LITELLM_DB_STORAGE_URL_PREFIX}{await self._contents.store(file_content)}"
|
||||
|
||||
async def download_file(self, storage_url: str) -> bytes:
|
||||
content: Final = await self._contents.load(storage_url_to_row_id(storage_url))
|
||||
if content is None:
|
||||
raise ValueError(f"No stored file content for {storage_url}")
|
||||
return content
|
||||
|
||||
async def delete_file(self, storage_url: str) -> None:
|
||||
await self._contents.delete(storage_url_to_row_id(storage_url))
|
||||
|
|
@ -6,32 +6,46 @@ based on the backend type. Backends use the same configuration as their correspo
|
|||
callbacks (e.g., azure_storage uses the same env vars as AzureBlobStorageLogger).
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
from .azure_blob_storage_backend import AzureBlobStorageBackend
|
||||
from .litellm_db_storage_backend import LITELLM_DB_STORAGE_BACKEND_NAME, LiteLLMDbStorageBackend
|
||||
from .storage_backend import BaseFileStorageBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
def get_storage_backend(backend_type: str) -> BaseFileStorageBackend:
|
||||
|
||||
def get_storage_backend(backend_type: str, prisma_client: "PrismaClient | None" = None) -> BaseFileStorageBackend:
|
||||
"""
|
||||
Factory function to create a storage backend instance.
|
||||
|
||||
Backends are configured using the same environment variables as their
|
||||
corresponding callbacks. For example, "azure_storage" uses the same
|
||||
env vars as AzureBlobStorageLogger.
|
||||
env vars as AzureBlobStorageLogger. "litellm_db" stores file bytes in the
|
||||
proxy's own database and needs the connected Prisma client.
|
||||
|
||||
Args:
|
||||
backend_type: Backend type identifier (e.g., "azure_storage")
|
||||
backend_type: Backend type identifier (e.g., "azure_storage", "litellm_db")
|
||||
prisma_client: The proxy's database client, required by "litellm_db"
|
||||
|
||||
Returns:
|
||||
BaseFileStorageBackend: Instance of the appropriate storage backend
|
||||
|
||||
Raises:
|
||||
ValueError: If backend_type is not supported
|
||||
ValueError: If backend_type is not supported, or "litellm_db" is asked for without a database
|
||||
"""
|
||||
verbose_logger.debug("Creating storage backend: type=%s", backend_type)
|
||||
|
||||
if backend_type == "azure_storage":
|
||||
return AzureBlobStorageBackend()
|
||||
else:
|
||||
raise ValueError(f"Unsupported storage backend type: {backend_type}. Supported types: azure_storage")
|
||||
if backend_type == LITELLM_DB_STORAGE_BACKEND_NAME:
|
||||
if prisma_client is None:
|
||||
raise ValueError(f"Storage backend {LITELLM_DB_STORAGE_BACKEND_NAME} requires a database-connected proxy")
|
||||
return LiteLLMDbStorageBackend(prisma_client)
|
||||
raise ValueError(
|
||||
f"Unsupported storage backend type: {backend_type}. "
|
||||
f"Supported types: azure_storage, {LITELLM_DB_STORAGE_BACKEND_NAME}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2163,6 +2163,8 @@ class BaseLLMHTTPHandler:
|
|||
e=e, litellm_params=litellm_params_dict
|
||||
)
|
||||
if should_retry and not hit_max_attempt:
|
||||
if logging_obj.baseline_cache_context is not None:
|
||||
await logging_obj.invalidate_baseline_cache_estimate("retried_request")
|
||||
verbose_logger.debug(
|
||||
"Anthropic /v1/messages: invalid thinking signature; "
|
||||
"stripping thinking blocks and retrying (attempt %s/%s).",
|
||||
|
|
|
|||
|
|
@ -14538,6 +14538,7 @@
|
|||
"supports_native_structured_output": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_thinking_cache_preservation": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_sampling_params": false,
|
||||
|
|
@ -14575,6 +14576,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_thinking_cache_preservation": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
|
|
@ -14726,6 +14728,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_thinking_cache_preservation": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
|
|
@ -14755,6 +14758,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_thinking_cache_preservation": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
|
|
@ -14787,6 +14791,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_thinking_cache_preservation": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
|
|
@ -14824,6 +14829,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_thinking_cache_preservation": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
|
|
@ -14859,6 +14865,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_thinking_cache_preservation": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
|
|
@ -14897,6 +14904,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_thinking_cache_preservation": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
|
|
@ -15014,6 +15022,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_thinking_cache_preservation": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
|
|
@ -15055,6 +15064,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_thinking_cache_preservation": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_native_structured_output": true,
|
||||
|
|
@ -41369,6 +41379,7 @@
|
|||
},
|
||||
"openrouter/deepseek/deepseek-v3.2": {
|
||||
"cache_read_input_token_cost": 1.345e-07,
|
||||
"deprecation_date": "2026-09-28",
|
||||
"input_cost_per_token": 2.69e-07,
|
||||
"input_cost_per_token_cache_hit": 1.345e-07,
|
||||
"litellm_provider": "openrouter",
|
||||
|
|
@ -41391,6 +41402,7 @@
|
|||
},
|
||||
"openrouter/deepseek/deepseek-v3.2-exp": {
|
||||
"cache_read_input_token_cost": 2e-08,
|
||||
"deprecation_date": "2026-09-28",
|
||||
"input_cost_per_token": 2.7e-07,
|
||||
"input_cost_per_token_cache_hit": 2e-08,
|
||||
"litellm_provider": "openrouter",
|
||||
|
|
@ -41498,21 +41510,22 @@
|
|||
"supports_web_search": false
|
||||
},
|
||||
"openrouter/deepseek/deepseek-v4-pro-0813": {
|
||||
"input_cost_per_token": 5.7816e-07,
|
||||
"input_cost_per_token": 1.32e-06,
|
||||
"input_cost_per_token_cache_hit": 4.4e-08,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 393216,
|
||||
"max_tokens": 393216,
|
||||
"max_output_tokens": 384000,
|
||||
"max_tokens": 384000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.73448e-06,
|
||||
"output_cost_per_token": 3.96e-06,
|
||||
"source": "https://openrouter.ai/api/v1/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"cache_read_input_token_cost": 1.8396e-08,
|
||||
"cache_read_input_token_cost": 4.4e-08,
|
||||
"off_peak_pricing": {"windows":[{"weekdays":["saturday","sunday"],"hours_utc":"00:00-00:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"00:00-01:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"04:00-06:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"10:00-00:00"}],"input_cost_per_token":6.6e-7,"output_cost_per_token":0.00000198,"cache_read_input_token_cost":2.2e-8},
|
||||
"supports_audio_input": false,
|
||||
"supports_pdf_input": false,
|
||||
"supports_vision": false,
|
||||
|
|
@ -42812,13 +42825,13 @@
|
|||
"supports_web_search": false
|
||||
},
|
||||
"openrouter/qwen/qwen3.5-35b-a3b": {
|
||||
"input_cost_per_token": 1.625e-07,
|
||||
"input_cost_per_token": 3.125e-07,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 262144,
|
||||
"max_output_tokens": 65536,
|
||||
"max_tokens": 65536,
|
||||
"max_output_tokens": 16384,
|
||||
"max_tokens": 16384,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.3e-06,
|
||||
"output_cost_per_token": 1.25e-06,
|
||||
"source": "https://openrouter.ai/api/v1/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -42827,7 +42840,7 @@
|
|||
"cache_read_input_token_cost": 1.5625e-07,
|
||||
"supports_audio_input": false,
|
||||
"supports_pdf_input": false,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_web_search": false
|
||||
},
|
||||
|
|
@ -43155,6 +43168,7 @@
|
|||
"output_cost_per_token": 1.2e-06,
|
||||
"cache_creation_input_token_cost": 0.0,
|
||||
"cache_read_input_token_cost": 3e-08,
|
||||
"deprecation_date": "2026-10-08",
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 204800,
|
||||
"max_output_tokens": 131072,
|
||||
|
|
@ -66523,9 +66537,9 @@
|
|||
"supports_web_search": false
|
||||
},
|
||||
"openrouter/z-ai/glm-5.3": {
|
||||
"input_cost_per_token": 9.1e-07,
|
||||
"output_cost_per_token": 2.86e-06,
|
||||
"cache_read_input_token_cost": 1.69e-07,
|
||||
"input_cost_per_token": 8.96e-07,
|
||||
"output_cost_per_token": 2.816e-06,
|
||||
"cache_read_input_token_cost": 1.664e-07,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 1310720,
|
||||
"max_output_tokens": 131072,
|
||||
|
|
@ -66544,9 +66558,9 @@
|
|||
"supports_web_search": false
|
||||
},
|
||||
"openrouter/qwen/qwen3.8-27b": {
|
||||
"input_cost_per_token": 2.14e-07,
|
||||
"output_cost_per_token": 2.55e-06,
|
||||
"cache_read_input_token_cost": 1.5e-07,
|
||||
"input_cost_per_token": 4.2e-07,
|
||||
"output_cost_per_token": 3e-06,
|
||||
"cache_read_input_token_cost": 8.5e-08,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 131072,
|
||||
|
|
@ -67212,9 +67226,9 @@
|
|||
"supports_web_search": true
|
||||
},
|
||||
"openrouter/deepseek/deepseek-v4-flash": {
|
||||
"input_cost_per_token": 4.032e-08,
|
||||
"output_cost_per_token": 8.064e-08,
|
||||
"cache_read_input_token_cost": 8.064e-09,
|
||||
"input_cost_per_token": 3.696e-08,
|
||||
"output_cost_per_token": 7.392e-08,
|
||||
"cache_read_input_token_cost": 7.392e-09,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 384000,
|
||||
|
|
@ -67470,8 +67484,8 @@
|
|||
"output_cost_per_token": 1.5e-07,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 262144,
|
||||
"max_output_tokens": 235929,
|
||||
"max_tokens": 235929,
|
||||
"max_output_tokens": 32768,
|
||||
"max_tokens": 32768,
|
||||
"mode": "chat",
|
||||
"source": "https://openrouter.ai/api/v1/models",
|
||||
"supports_audio_input": false,
|
||||
|
|
@ -68027,6 +68041,7 @@
|
|||
"input_cost_per_token": 2.7e-07,
|
||||
"output_cost_per_token": 1e-06,
|
||||
"cache_read_input_token_cost": 1.35e-07,
|
||||
"deprecation_date": "2026-09-28",
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 163840,
|
||||
"max_output_tokens": 32768,
|
||||
|
|
@ -68768,6 +68783,7 @@
|
|||
"supports_web_search": false
|
||||
},
|
||||
"openrouter/deepseek/deepseek-r1-distill-llama-70b": {
|
||||
"deprecation_date": "2026-09-28",
|
||||
"input_cost_per_token": 8e-07,
|
||||
"output_cost_per_token": 8e-07,
|
||||
"litellm_provider": "openrouter",
|
||||
|
|
@ -71267,14 +71283,14 @@
|
|||
"supports_web_search": false
|
||||
},
|
||||
"openrouter/~deepseek/deepseek-pro-latest": {
|
||||
"cache_read_input_token_cost": 1.8396e-08,
|
||||
"input_cost_per_token": 5.7816e-07,
|
||||
"cache_read_input_token_cost": 1.9228e-08,
|
||||
"input_cost_per_token": 5.7684e-07,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 393216,
|
||||
"max_tokens": 393216,
|
||||
"max_output_tokens": 384000,
|
||||
"max_tokens": 384000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.73448e-06,
|
||||
"output_cost_per_token": 1.73052e-06,
|
||||
"source": "https://openrouter.ai/api/v1/models",
|
||||
"supports_audio_input": false,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -72041,6 +72057,7 @@
|
|||
"supports_web_search": false
|
||||
},
|
||||
"openrouter/baidu/ernie-4.5-vl-424b-a47b": {
|
||||
"deprecation_date": "2026-10-08",
|
||||
"input_cost_per_token": 4.2e-07,
|
||||
"litellm_provider": "openrouter",
|
||||
"max_input_tokens": 123000,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ maintains per (api_key, session_id, router_name).
|
|||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from litellm.types.llms.base import LiteLLMPydanticObjectBase
|
||||
|
||||
|
||||
|
|
@ -22,18 +24,25 @@ class LiteLLM_AutoRouterSession(LiteLLMPydanticObjectBase):
|
|||
turns: int
|
||||
spend: float
|
||||
saved_spend: float
|
||||
savings_estimated_turns: int = 0
|
||||
savings_estimated_actual_spend: float = 0.0
|
||||
savings_estimated_saved_spend: float = 0.0
|
||||
savings_estimated_baseline_models: Mapping[str, int] = Field(default_factory=dict)
|
||||
classifier_cost: float
|
||||
tier_turns: Mapping[str, int]
|
||||
baseline_models: Mapping[str, int]
|
||||
|
||||
@property
|
||||
def baseline_model(self) -> str | None:
|
||||
"""The baseline most of this session's turns were priced against, or None when no turn recorded one.
|
||||
"""The baseline most covered turns were priced against, or None when none were estimated.
|
||||
|
||||
A router reconfigured mid-session leaves turns priced against two baselines; the row keeps both
|
||||
counts, and the label is the one that priced the most money-carrying turns rather than whatever the
|
||||
router is configured with now.
|
||||
"""
|
||||
if not self.baseline_models:
|
||||
if not self.savings_estimated_baseline_models:
|
||||
return None
|
||||
return max(self.baseline_models, key=lambda model: (self.baseline_models[model], model))
|
||||
return max(
|
||||
self.savings_estimated_baseline_models,
|
||||
key=lambda model: (self.savings_estimated_baseline_models[model], model),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2867,7 +2867,7 @@ class MCPServerManager:
|
|||
normalize_server_name(value) for value in (*iter_known_server_prefixes(server), server.name) if value
|
||||
)
|
||||
|
||||
def _server_exposes_tool(self, server: MCPServer, tool_name: str) -> bool:
|
||||
def server_exposes_tool(self, server: MCPServer, tool_name: str) -> bool:
|
||||
owned: Final = self._owned_mapping_values(server)
|
||||
mapped_owners: Final = (
|
||||
self.tool_name_to_mcp_server_name_mapping.get(spelling)
|
||||
|
|
@ -2875,6 +2875,20 @@ class MCPServerManager:
|
|||
)
|
||||
return any(owner is not None and normalize_server_name(owner) in owned for owner in mapped_owners)
|
||||
|
||||
def _known_prefix_to_server(self) -> Mapping[str, MCPServer]:
|
||||
"""Every prefix form a tool name may carry, keyed to its server; a form two servers share
|
||||
stays with the one registered first."""
|
||||
return {
|
||||
normalize_server_name(known_prefix): server
|
||||
for server in reversed(tuple(self.get_registry().values()))
|
||||
for known_prefix in iter_known_server_prefixes(server)
|
||||
}
|
||||
|
||||
def server_owning_tool_name_prefix(self, tool_name: str) -> MCPServer | None:
|
||||
prefix_to_server: Final = self._known_prefix_to_server()
|
||||
matched: Final = match_known_server_prefix(tool_name, prefix_to_server.keys())
|
||||
return None if matched is None else prefix_to_server.get(matched[0])
|
||||
|
||||
def remove_server(self, mcp_server: LiteLLM_MCPServerTable):
|
||||
"""
|
||||
Remove a server from the registry
|
||||
|
|
@ -6114,7 +6128,7 @@ class MCPServerManager:
|
|||
if mcp_server is None:
|
||||
raise ValueError(f"Tool {name} not found")
|
||||
|
||||
if resolved_by_server_name_only and not self._server_exposes_tool(mcp_server, name):
|
||||
if resolved_by_server_name_only and not self.server_exposes_tool(mcp_server, name):
|
||||
raise ValueError(f"Tool {name} not found")
|
||||
|
||||
return mcp_server
|
||||
|
|
@ -6475,15 +6489,7 @@ class MCPServerManager:
|
|||
MCPServer if found, None otherwise
|
||||
"""
|
||||
registry_servers: Final = list(self.get_registry().values())
|
||||
|
||||
# Build prefix → server lookup covering every known form a tool name
|
||||
# may take (alias / server_name / server_id / short ID). This is what
|
||||
# makes the short-prefix mode work without breaking historical names.
|
||||
prefix_to_server: Final[dict[str, MCPServer]] = {}
|
||||
for server in registry_servers:
|
||||
for known_prefix in iter_known_server_prefixes(server):
|
||||
normalised = normalize_server_name(known_prefix)
|
||||
prefix_to_server.setdefault(normalised, server)
|
||||
prefix_to_server: Final = self._known_prefix_to_server()
|
||||
|
||||
# First try with the original tool name
|
||||
if tool_name in self.tool_name_to_mcp_server_name_mapping:
|
||||
|
|
@ -6501,7 +6507,7 @@ class MCPServerManager:
|
|||
if matched is not None:
|
||||
matched_prefix, original_tool_name = matched
|
||||
matched_server: Final = prefix_to_server.get(matched_prefix)
|
||||
if matched_server is not None and self._server_exposes_tool(matched_server, original_tool_name):
|
||||
if matched_server is not None and self.server_exposes_tool(matched_server, original_tool_name):
|
||||
return matched_server
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -2888,6 +2888,40 @@ if MCP_AVAILABLE:
|
|||
headers={"WWW-Authenticate": get_byok_www_authenticate()},
|
||||
)
|
||||
|
||||
async def _list_tools_before_first_call(
|
||||
server: MCPServer | None,
|
||||
tool_name: str,
|
||||
allowed_mcp_servers: list[MCPServer],
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
mcp_auth_header: str | None,
|
||||
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
|
||||
oauth2_headers: dict[str, str] | None,
|
||||
raw_headers: dict[str, str] | None,
|
||||
) -> None:
|
||||
"""List ``server`` with the caller's own credentials when it does not yet expose ``tool_name`` here.
|
||||
|
||||
The startup fill skips a server whose upstream wants the caller's token, and mcp 2 no
|
||||
longer lists before an uncached tools/call, so a worker that has not served tools/list
|
||||
for this caller would otherwise answer 404 for a tool the caller can see. Gating on the
|
||||
requested tool, not on any prior listing, keeps callers with different upstream catalogs
|
||||
from masking each other.
|
||||
"""
|
||||
if server is None or global_mcp_server_manager.server_exposes_tool(server, tool_name):
|
||||
return
|
||||
if all(allowed.server_id != server.server_id for allowed in allowed_mcp_servers):
|
||||
return
|
||||
try:
|
||||
await _get_tools_from_mcp_servers(
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_servers=[server.server_id],
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # best effort: resolution below answers as it did before
|
||||
verbose_logger.debug("MCP tools/call: listing %s before its first call failed: %s", server.name, e)
|
||||
|
||||
async def execute_mcp_tool(
|
||||
name: str,
|
||||
arguments: dict[str, object],
|
||||
|
|
@ -2948,6 +2982,27 @@ if MCP_AVAILABLE:
|
|||
all_registry_prefixes.add(normalize_server_name(known_prefix))
|
||||
name_is_prefixed = is_tool_name_prefixed(name, known_server_prefixes=all_registry_prefixes)
|
||||
|
||||
first_call_target: Final = (
|
||||
requested_server
|
||||
if requested_server is not None and not name_is_prefixed
|
||||
else global_mcp_server_manager.server_owning_tool_name_prefix(name)
|
||||
)
|
||||
first_call_tool_name: Final = (
|
||||
name
|
||||
if first_call_target is None or (requested_server is not None and not name_is_prefixed)
|
||||
else strip_known_server_prefix(name, first_call_target)
|
||||
)
|
||||
await _list_tools_before_first_call(
|
||||
server=first_call_target,
|
||||
tool_name=first_call_tool_name,
|
||||
allowed_mcp_servers=allowed_mcp_servers,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
|
||||
if requested_server is not None and not name_is_prefixed:
|
||||
# REST callers may pass server_id with the upstream tool name (no
|
||||
# LiteLLM prefix). The first segment is not a registered server
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,4 @@
|
|||
:HL["/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/3rynlyl14avb-.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/3146e697tym4_.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.2bn3s6zvc0dyp.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
0:{"tree":{"name":"","param":null,"prefetchHints":4176,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":4192,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":4256,"slots":null}}}}},"staleTime":300,"buildId":"N8M8GUEWcUrwZCaluei8R"}
|
||||
0:{"tree":{"name":"","param":null,"prefetchHints":4176,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":4192,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":4256,"slots":null}}}}},"staleTime":300,"buildId":"kXnLzJ6ylsRPmgSkCkCKM"}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue