chore: merge litellm_internal_staging into devin_ai_fix_lit5034_empty_choices

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-07 02:16:26 +00:00
commit 4068b4a2f0
3266 changed files with 154768 additions and 90507 deletions

View file

@ -88,6 +88,59 @@ commands:
rm -f /tmp/uv-install.sh
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV"
export PATH="$HOME/.local/bin:$PATH"
install_node:
description: "Install the Node.js version pinned in ui/litellm-dashboard/.nvmrc (24.19.0, which bundles npm 11.17.0) with checksum verification, and prepend it to PATH. Run this on any executor whose image does not already ship that version, or `npm ci` in ui/litellm-dashboard fails EBADENGINE against the engines floor. Installs into /opt/node rather than over /usr/local on purpose: cimg/python:*-browsers ships its own node there, and unpacking the tarball on top of it leaves npm 11.17 files merged with the image's npm 11.9 tree, which reports the new version and then exits 1 on `npm ci` with no error text at all. Requires checkout, which the .nvmrc drift check reads."
steps:
- run:
name: Install Node.js 24.19.0
command: |
NODE_VERSION="24.19.0"
NODE_TARBALL="node-v${NODE_VERSION}-linux-x64.tar.xz"
NODE_EXPECTED_SHA="14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647"
NVMRC_VERSION="$(tr -d '[:space:]' < ui/litellm-dashboard/.nvmrc)"
if [ "$NVMRC_VERSION" != "$NODE_VERSION" ]; then
echo "install_node: ui/litellm-dashboard/.nvmrc pins ${NVMRC_VERSION} but this command pins ${NODE_VERSION}; update NODE_VERSION and NODE_EXPECTED_SHA together" >&2
exit 1
fi
curl -sSLf -o "/tmp/${NODE_TARBALL}" "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}"
echo "${NODE_EXPECTED_SHA} /tmp/${NODE_TARBALL}" | sha256sum -c -
sudo mkdir -p /opt/node
sudo tar -xJf "/tmp/${NODE_TARBALL}" -C /opt/node --strip-components=1
rm -f "/tmp/${NODE_TARBALL}"
echo 'export PATH="/opt/node/bin:$PATH"' >> "$BASH_ENV"
export PATH="/opt/node/bin:$PATH"
node --version
npm --version
install_rust:
description: "Install pinned rustup (1.28.2) and Rust toolchain (1.97.1) with checksum verification. Adds ~/.cargo/bin to PATH. Run this before any `uv sync` or `uv build` of the workspace: the root package builds litellm-rust through maturin, and on an image without cargo maturin fetches an unpinned rustup and a floating toolchain by itself."
steps:
- run:
name: Install Rust (rustup 1.28.2, toolchain 1.97.1)
command: |
case "$(uname -m)" in
x86_64)
RUSTUP_TRIPLE=x86_64-unknown-linux-gnu
RUSTUP_SHA256=20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c
;;
aarch64)
RUSTUP_TRIPLE=aarch64-unknown-linux-gnu
RUSTUP_SHA256=e3853c5a252fca15252d07cb23a1bdd9377a8c6f3efa01531109281ae47f841c
;;
*)
echo "install_rust: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
curl -sSLf -o /tmp/rustup-init \
"https://static.rust-lang.org/rustup/archive/1.28.2/${RUSTUP_TRIPLE}/rustup-init"
echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c -
chmod +x /tmp/rustup-init
/tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain 1.97.1
rm -f /tmp/rustup-init
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> "$BASH_ENV"
export PATH="$HOME/.cargo/bin:$PATH"
rustc --version
cargo --version
start_postgres:
description: "Start a postgres-db container on port 5432 and wait until it accepts connections."
parameters:
@ -163,6 +216,26 @@ commands:
done
echo "fake OpenAI endpoint did not become ready" >&2
exit 1
start_cost_center_service:
description: "Start the stand-in cost center validation service (tests/store_model_in_db_tests/cost_center_service.py) on host port 9414 and wait until healthy. The proxy's team-metadata validator (team_metadata_validator_e2e.py, impl 'http') reaches it via TEAM_METADATA_VALIDATION_SERVICE_URL=http://host.docker.internal:9414/validate. Run after uv deps are synced."
steps:
- run:
name: Start cost center validation service
background: true
command: |
uv run --no-sync python tests/store_model_in_db_tests/cost_center_service.py --host 0.0.0.0 --port 9414
- run:
name: Wait for cost center validation service
command: |
for i in $(seq 1 30); do
if curl -sf http://localhost:9414/health >/dev/null 2>&1; then
echo "cost center validation service is up"
exit 0
fi
sleep 1
done
echo "cost center validation service did not become ready" >&2
exit 1
setup_litellm_enterprise_pip:
steps:
- run:
@ -178,6 +251,7 @@ commands:
- checkout
- setup_google_dns
- install_uv
- install_rust
- restore_cache:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
@ -281,6 +355,33 @@ jobs:
uv build --wheel --out-dir dist
uv run --no-sync python tests/windows_tests/check_windows_wheel_install.py
base_sdk_install:
docker:
- image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c
auth:
username: ${DOCKERHUB_USERNAME}
password: ${DOCKERHUB_PASSWORD}
working_directory: ~/project
steps:
- checkout
- setup_google_dns
- install_uv
- install_rust
- run:
name: Build the wheel
environment:
UV_HTTP_TIMEOUT: "300"
command: |
uv build --wheel --out-dir dist
- run:
name: Install the wheel with no extras and smoke-check it
environment:
UV_HTTP_TIMEOUT: "300"
command: |
uv venv /tmp/base-sdk --python 3.12
VIRTUAL_ENV=/tmp/base-sdk uv pip install dist/*.whl
/tmp/base-sdk/bin/python tests/base_sdk_tests/check_base_sdk_install.py
local_testing_part1:
docker:
- &python312_image
@ -298,6 +399,7 @@ jobs:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -371,6 +473,7 @@ jobs:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -445,6 +548,7 @@ jobs:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -496,6 +600,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -562,6 +667,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -602,6 +708,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -643,6 +750,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -676,6 +784,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- restore_cache:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
@ -726,6 +835,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- restore_cache:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
@ -777,6 +887,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -810,6 +921,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- restore_cache:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
@ -856,6 +968,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -902,6 +1015,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -944,6 +1058,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -990,6 +1105,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1037,6 +1153,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- restore_cache:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
@ -1077,6 +1194,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1122,6 +1240,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1166,6 +1285,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1198,6 +1318,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1241,6 +1362,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1285,6 +1407,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1329,6 +1452,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1360,6 +1484,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1406,6 +1531,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1451,6 +1577,7 @@ jobs:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1501,6 +1628,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1525,6 +1653,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1551,6 +1680,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1652,6 +1782,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1747,6 +1878,7 @@ jobs:
at: ~/project
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1835,6 +1967,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -1918,6 +2051,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -2050,6 +2184,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -2136,6 +2271,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -2232,12 +2368,14 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
uv sync --frozen --all-groups --all-extras --python 3.12
- start_postgres
- start_fake_openai_endpoint
- start_cost_center_service
- attach_workspace:
at: ~/project
- run:
@ -2257,11 +2395,13 @@ jobs:
-e STORE_MODEL_IN_DB="True" \
-e LITELLM_MASTER_KEY="sk-1234" \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e TEAM_METADATA_VALIDATION_SERVICE_URL=http://host.docker.internal:9414/validate \
-e LITELLM_LICENSE=$LITELLM_LICENSE \
-e LITELLM_LOG=ERROR \
--add-host host.docker.internal:host-gateway \
--name my-app \
-v $(pwd)/litellm/proxy/example_config_yaml/store_model_db_config.yaml:/app/config.yaml \
-v $(pwd)/litellm/proxy/example_config_yaml/team_metadata_validator_e2e.py:/app/team_metadata_validator_e2e.py \
litellm-docker-database:ci \
--config /app/config.yaml \
--port 4000
@ -2307,6 +2447,7 @@ jobs:
- setup_google_dns
# Remove Docker CLI installation since it's already available in machine executor
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -2388,6 +2529,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -2473,20 +2615,9 @@ jobs:
bundle exec rspec
no_output_timeout: 30m
# Install Node.js directly from nodejs.org with SHA256 verification,
# instead of piping NodeSource's setup_18.x apt-repo installer into
# instead of piping NodeSource's setup_24.x apt-repo installer into
# sudo bash (which runs a mutable upstream script unattended).
- run:
name: Install Node.js 18.20.8
command: |
NODE_VERSION="18.20.8"
NODE_TARBALL="node-v${NODE_VERSION}-linux-x64.tar.xz"
NODE_EXPECTED_SHA="5467ee62d6af1411d46b6a10e3fb5cacc92734dbcef465fea14e7b90993001c9"
curl -sSLf -o "/tmp/${NODE_TARBALL}" "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}"
echo "${NODE_EXPECTED_SHA} /tmp/${NODE_TARBALL}" | sha256sum -c -
sudo tar -xJf "/tmp/${NODE_TARBALL}" -C /usr/local --strip-components=1
rm -f "/tmp/${NODE_TARBALL}"
node --version
npm --version
- install_node
- run:
name: Install Node.js test dependencies
@ -2527,6 +2658,7 @@ jobs:
- skip_if_unrelated_changes
- setup_google_dns
- install_uv
- install_rust
- run:
name: Install Dependencies
command: |
@ -2614,7 +2746,7 @@ jobs:
ui_build:
docker:
- image: cimg/node:20.19@sha256:35e64883e8d21bc345b0a7b04c35ee46442c127607ed1d8d7d37d8a1ed76db81
- image: cimg/node:24.19@sha256:8966565f07189a67d64d6808a2b127f31dafae566508e3547f55640e1070bfad
auth:
username: ${DOCKERHUB_USERNAME}
password: ${DOCKERHUB_PASSWORD}
@ -2658,7 +2790,7 @@ jobs:
ui_unit_tests:
docker:
- image: cimg/node:20.19@sha256:35e64883e8d21bc345b0a7b04c35ee46442c127607ed1d8d7d37d8a1ed76db81
- image: cimg/node:24.19@sha256:8966565f07189a67d64d6808a2b127f31dafae566508e3547f55640e1070bfad
auth:
username: ${DOCKERHUB_USERNAME}
password: ${DOCKERHUB_PASSWORD}
@ -2716,7 +2848,9 @@ jobs:
- skip_if_unrelated_changes:
category: client
- setup_google_dns
- install_node
- install_uv
- install_rust
- restore_cache:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
@ -2731,7 +2865,7 @@ jobs:
- ~/.cache/uv
- restore_cache:
keys:
- ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
- ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
- run:
name: Install Node dependencies and Playwright
# The cimg/python:3.12-browsers image already ships the Chromium system
@ -2746,7 +2880,7 @@ jobs:
npm ci
npx playwright install chromium
- save_cache:
key: ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
key: ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
paths:
- ui/litellm-dashboard/node_modules
- tests/e2e/ui/node_modules
@ -2858,7 +2992,9 @@ jobs:
- skip_if_unrelated_changes:
category: client
- setup_google_dns
- install_node
- install_uv
- install_rust
- restore_cache:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
@ -2873,7 +3009,7 @@ jobs:
- ~/.cache/uv
- restore_cache:
keys:
- ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
- ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
- run:
name: Install Node dependencies and Playwright
command: |
@ -2883,7 +3019,7 @@ jobs:
npm ci
npx playwright install chromium
- save_cache:
key: ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
key: ui-e2e-node-deps-v4-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
paths:
- ui/litellm-dashboard/node_modules
- tests/e2e/ui/node_modules
@ -3031,6 +3167,8 @@ workflows:
only:
- main
- /litellm_.*/
- base_sdk_install:
filters: *main_branches
- local_testing_part1:
filters: *main_branches
- local_testing_part2:

46
.flake8
View file

@ -1,46 +0,0 @@
[flake8]
ignore =
# The following ignores can be removed when formatting using black
W191,W291,W292,W293,W391,W504
E101,E111,E114,E116,E117,E121,E122,E123,E124,E125,E126,E127,E128,E129,E131,
E201,E202,E221,E222,E225,E226,E231,E241,E251,E252,E261,E265,E271,E272,E275,
E301,E302,E303,E305,E306,
# line break before binary operator
W503,
# inline comment should start with '# '
E262,
# too many leading '#' for block comment
E266,
# multiple imports on one line
E401,
# module level import not at top of file
E402,
# Line too long (82 > 79 characters)
E501,
# comparison to None should be 'if cond is None:'
E711,
# comparison to True should be 'if cond is True:' or 'if cond:'
E712,
# do not compare types, for exact checks use `is` / `is not`, for instance checks use `isinstance()`
E721,
# do not use bare 'except'
E722,
# x is imported but unused
F401,
# 'from . import *' used; unable to detect undefined names
F403,
# x may be undefined, or defined from star imports:
F405,
# f-string is missing placeholders
F541,
# dictionary key '' repeated with different values
F601,
# redefinition of unused x from line 123
F811,
# undefined name x
F821,
# local variable x is assigned to but never used
F841,
# https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8
extend-ignore = E203

View file

@ -17,3 +17,24 @@
# style: unify ruff format width on 120 (#31518)
48b5a5a0cc5a694a11219416ee0b6eb6e620e74e
# refactor(imports): move collections.abc names out of typing (#35495)
397e8e4918777e4e60a7f5e88699e0a9a7dabb3d
# refactor(lint): apply every safe ruff autofix and zero 28 strict-rule budgets (#35495)
b604e2b20c6db2099085a2f0e59b7e99e87eed6f
# refactor(logging): drop redundant !s conversion flags from f-strings (#35546)
7b2d3440cba3160277470f7a0180098ae9b87864
# perf: build log messages lazily so filtered-out log records cost nothing (#35703)
c9887a1f94bc1e7e4bdfe64d640f0509a0bc19dd
# feat(lint): enforce Final on locals and freeze function parameters (#35807)
2708620d6a599cc73c1950a942d26ac26a7ed3d4
# chore(lint): remove litellm/types from the ruff lint exclusion (#35926)
4e32a8bf6a1e1af1e04b67c759841ccef44b2235
# chore(lint): strip inert type: ignore comments and zero LIT009/LIT010/LIT011 headroom (#35928)
338e411103ad5d7003e97f34f04fa36bca542dbe

159
.github/ci-coverage-allowlist.yml vendored Normal file
View file

@ -0,0 +1,159 @@
description: >-
Paths deliberately outside CI coverage, each with the reason it is exempt.
assert_ci_coverage.py fails when a test file or Dockerfile is neither invoked
by a job nor listed here, so every entry below is a decision on the record.
test_paths:
- reason: >-
The end-to-end suite runs against a deployed proxy from its own in-cluster rig rather than
from a pull request; it needs a live gateway and provider credentials no PR job holds
paths:
- tests/e2e
- reason: >-
The documentation and code-quality workflows execute four files in this directory by name as
scripts and pytest never collects the directory, so these six run nowhere; listed individually
so a seventh cannot inherit the exemption
paths:
- tests/documentation_tests/test_exception_types.py
- tests/documentation_tests/test_general_setting_keys.py
- tests/documentation_tests/test_optional_params.py
- tests/documentation_tests/test_readme_providers.py
- tests/documentation_tests/test_requests_lib_usage.py
- tests/documentation_tests/test_standard_logging_payload.py
- reason: >-
Sibling files here are executed by name from the code-quality workflow; this one is referenced
by no job
paths:
- tests/code_coverage_tests/test_aio_http_image_conversion.py
- reason: >-
A second mirror of the package tree living beside tests/test_litellm, which is the mirror the
repo convention names; only test_no_hardcoded_secrets.py is invoked, from the linting
workflow, and whether this directory should exist at all is unresolved
paths:
- tests/litellm/a2a_protocol/providers/pydantic_ai_agents/test_pydantic_ai_agent_headers.py
- tests/litellm/a2a_protocol/providers/pydantic_ai_agents/test_pydantic_ai_agent_transformation.py
- tests/litellm/integrations/helicone/test_helicone_gemini.py
- tests/litellm/litellm_core_utils/test_json_schema_validation.py
- tests/litellm/llms/anthropic/test_anthropic_reasoning_effort.py
- tests/litellm/llms/anthropic/test_anthropic_schema_filter.py
- tests/litellm/llms/azure/test_azure_embedding.py
- tests/litellm/llms/bedrock/embed/test_embedding.py
- tests/litellm/llms/bedrock/test_nova_imported_models.py
- tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py
- tests/litellm/llms/gradient_ai/chat/test_gradient_ai_chat_transformation.py
- tests/litellm/llms/oci/chat/test_oci_chat_transformation.py
- tests/litellm/llms/openai_like/test_abliteration_provider.py
- tests/litellm/llms/openai_like/test_assemblyai_provider.py
- tests/litellm/llms/openai_like/test_empiriolabs_provider.py
- tests/litellm/llms/vertex_ai/agent_engine/test_transformation.py
- tests/litellm/llms/vertex_ai/gemini/test_transformation.py
- tests/litellm/llms/vertex_ai/text_to_speech/test_transformation.py
- tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py
- tests/litellm/proxy/agent_endpoints/test_agent_rbac.py
- tests/litellm/proxy/common_utils/test_rbac_utils.py
- tests/litellm/proxy/management_endpoints/test_common_utils.py
- tests/litellm/proxy/management_endpoints/test_cost_estimate_endpoint.py
- tests/litellm/proxy/test_claude_code_marketplace.py
- tests/litellm/proxy/test_init_litellm_callbacks.py
- tests/litellm/proxy/test_prisma_engine_watchdog.py
- tests/litellm/proxy/vector_store_endpoints/test_vector_store_rbac.py
- tests/litellm/test_bedrock_extended_beta_models.py
- tests/litellm/test_bedrock_nemotron_super.py
- tests/litellm/test_proxy_auth.py
- tests/litellm/test_router_retry_backoff_headers.py
- tests/litellm/test_sambanova_model_metadata.py
- tests/litellm/test_stream_chunk_builder_images.py
- reason: >-
Legacy proxy suite superseded by the proxy shards; no job invokes it and whether it still
describes supported behaviour is unresolved
paths:
- tests/old_proxy_tests/tests/test_anthropic_context_caching.py
- tests/old_proxy_tests/tests/test_anthropic_sdk.py
- tests/old_proxy_tests/tests/test_async.py
- tests/old_proxy_tests/tests/test_gemini_context_caching.py
- tests/old_proxy_tests/tests/test_langchain_embedding.py
- tests/old_proxy_tests/tests/test_langchain_request.py
- tests/old_proxy_tests/tests/test_llamaindex.py
- tests/old_proxy_tests/tests/test_mistral_sdk.py
- tests/old_proxy_tests/tests/test_openai_embedding.py
- tests/old_proxy_tests/tests/test_openai_exception_request.py
- tests/old_proxy_tests/tests/test_openai_request.py
- tests/old_proxy_tests/tests/test_openai_request_with_traceparent.py
- tests/old_proxy_tests/tests/test_openai_simple_embedding.py
- tests/old_proxy_tests/tests/test_openai_tts_request.py
- tests/old_proxy_tests/tests/test_pass_through_langfuse.py
- tests/old_proxy_tests/tests/test_q.py
- tests/old_proxy_tests/tests/test_simple_traceparent_openai.py
- tests/old_proxy_tests/tests/test_vertex_sdk_forward_headers.py
- tests/old_proxy_tests/tests/test_vtx_embedding.py
- tests/old_proxy_tests/tests/test_vtx_sdk_embedding.py
- reason: >-
No job invokes this suite and its files mix pure transformation tests with ones driving live
vendor vector stores, so assigning them needs a per-file decision
paths:
- tests/vector_store_tests/rag/test_rag_bedrock.py
- tests/vector_store_tests/rag/test_rag_openai.py
- tests/vector_store_tests/rag/test_rag_s3_vectors.py
- tests/vector_store_tests/rag/test_rag_vertex_ai.py
- tests/vector_store_tests/test_azure_ai_vector_store.py
- tests/vector_store_tests/test_azure_vector_store.py
- tests/vector_store_tests/test_bedrock_vector_store.py
- tests/vector_store_tests/test_gemini_vector_store.py
- tests/vector_store_tests/test_milvus_vector_store.py
- tests/vector_store_tests/test_openai_vector_store.py
- tests/vector_store_tests/test_ragflow_vector_store.py
- tests/vector_store_tests/test_s3_vectors_vector_store.py
- tests/vector_store_tests/test_vertex_ai_search_api_vector_store.py
- tests/vector_store_tests/test_vertex_ai_vector_store.py
- reason: >-
Throughput and memory-growth measurements whose runtime and variance make them unsuitable for
a per-pull-request job
paths:
- tests/load_tests/test_datadog_load_test.py
- tests/load_tests/test_langsmith_load_test.py
- tests/load_tests/test_linear_memory_growth.py
- tests/load_tests/test_memory_usage.py
- tests/load_tests/test_otel_load_test.py
- tests/load_tests/test_vertex_embeddings_load_test.py
- tests/load_tests/test_vertex_load_tests.py
- reason: >-
Third-party integration tests that skip themselves without OCI configuration or sandbox
credentials, neither of which a pull request job holds
paths:
- tests/integration/sandbox/test_e2b_sandbox.py
- tests/integration/test_oci_integration.py
- tests/integration/test_oci_proxy_integration.py
- reason: >-
Two prompt-factory tests sitting at the top level of tests/ instead of under the
tests/test_litellm mirror the shards enumerate; they need moving rather than a shard entry
paths:
- tests/litellm_core_utils/test_anthropic_dedup_factory.py
- tests/litellm_core_utils/test_bedrock_converse_dedup_factory.py
- reason: >-
A unit test for the proxy-extras package that no job invokes, while the package's other tests
live under tests/proxy_migration_tests
paths:
- tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py
dockerfiles:
- reason: >-
The componentized images the microservices chart deploys are built by no job; wiring both into
the scan workflow costs a full image build each and is deferred to a change that prices the
whole set
paths:
- backend/Dockerfile
- gateway/Dockerfile
- reason: >-
The dashboard container is a static Next.js export served by nginx, and the dashboard build
and lint workflows already exercise that output, so building the image adds no signal about it
paths:
- ui/Dockerfile
- reason: >-
The Rust gateway ships as its own chart and package with a separate release pipeline, so its
image is not part of this repo's Python image set
paths:
- litellm-rust/crates/ai-gateway/Dockerfile
- reason: >-
An example image under cookbook/ that is documentation rather than a shipped artifact
paths:
- cookbook/litellm-ollama-docker-image/Dockerfile

262
.github/scripts/assert_ci_coverage.py vendored Normal file
View file

@ -0,0 +1,262 @@
from __future__ import annotations
import pathlib
import re
import sys
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
import yaml
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows"
CIRCLECI_CONFIG = REPO_ROOT / ".circleci" / "config.yml"
ALLOWLIST_FILE = REPO_ROOT / ".github" / "ci-coverage-allowlist.yml"
TESTS_ROOT = REPO_ROOT / "tests"
ALLOWLIST_KEYS = frozenset({"description", "test_paths", "dockerfiles"})
PATH_FILTER_KEYS = frozenset({"paths", "paths-ignore"})
TEST_PATH_KEYS = frozenset({"test-path", "test-paths"})
DOCKERFILE_INPUT_KEYS = frozenset({"file", "dockerfile"})
TEST_RUNNER_RE = re.compile(r"\bpytest\b|\bcircleci tests\b|\bhelm unittest\b|\bplaywright test\b|\bpython[0-9.]*\s")
IMAGE_BUILD_RE = re.compile(r"\bdocker\s+(?:buildx\s+)?build\b")
TEST_TOKEN_RE = re.compile(r"tests/[A-Za-z0-9_./*?-]+")
DOCKERFILE_TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]*Dockerfile[A-Za-z0-9_.-]*")
COMMENT_RE = re.compile(r"^\s*#.*$", re.MULTILINE)
GLOB_CHARS = frozenset("*?")
@dataclass(frozen=True, slots=True)
class AllowEntry:
paths: tuple[str, ...]
reason: str
@dataclass(frozen=True, slots=True)
class Allowlist:
test_paths: tuple[AllowEntry, ...]
dockerfiles: tuple[AllowEntry, ...]
def covers_test(self, relative_path: str) -> bool:
return any(_token_covers(path, relative_path) for entry in self.test_paths for path in entry.paths)
def covers_dockerfile(self, relative_path: str) -> bool:
return any(relative_path == path for entry in self.dockerfiles for path in entry.paths)
@dataclass(frozen=True, slots=True)
class Scalar:
key: str
value: str
@dataclass(frozen=True, slots=True)
class Finding:
subject: str
detail: str
def _scalars(node: object, key: str) -> tuple[Scalar, ...]:
if isinstance(node, str):
return (Scalar(key=key, value=node),)
if isinstance(node, Mapping):
return tuple(
scalar
for child_key, value in node.items()
if child_key not in PATH_FILTER_KEYS
for scalar in _scalars(value, str(child_key))
)
if isinstance(node, Sequence):
return tuple(scalar for item in node for scalar in _scalars(item, key))
return ()
def _config_files() -> tuple[pathlib.Path, ...]:
workflows = tuple(sorted(path for path in WORKFLOW_DIR.iterdir() if path.suffix in (".yml", ".yaml")))
circleci = (CIRCLECI_CONFIG,) if CIRCLECI_CONFIG.is_file() else ()
return workflows + circleci
def _all_scalars() -> tuple[Scalar, ...]:
return tuple(
scalar
for path in _config_files()
for scalar in _scalars(yaml.safe_load(path.read_text(encoding="utf-8")), path.name)
)
def _uncommented(value: str) -> str:
return COMMENT_RE.sub("", value)
def _invoked_test_tokens(scalars: Iterable[Scalar]) -> frozenset[str]:
return frozenset(
match.group(0).rstrip("/")
for scalar in scalars
if scalar.key in TEST_PATH_KEYS or TEST_RUNNER_RE.search(scalar.value)
for match in TEST_TOKEN_RE.finditer(_uncommented(scalar.value))
)
def _built_dockerfile_tokens(scalars: Iterable[Scalar]) -> frozenset[str]:
return frozenset(
match.group(0)
for scalar in scalars
if scalar.key in DOCKERFILE_INPUT_KEYS or IMAGE_BUILD_RE.search(scalar.value)
for match in DOCKERFILE_TOKEN_RE.finditer(_uncommented(scalar.value))
)
def _glob_to_regex(token: str) -> re.Pattern[str]:
parts = re.split(r"(\*\*/|\*\*|\*|\?)", token)
translated = "".join(
{"**/": r"(?:.*/)?", "**": r".*", "*": r"[^/]*", "?": r"[^/]"}.get(part, re.escape(part)) for part in parts
)
return re.compile(rf"{translated}(?:/.*)?$")
def _token_covers(token: str, relative_path: str) -> bool:
if GLOB_CHARS & set(token):
return _glob_to_regex(token).match(relative_path) is not None
return relative_path == token or relative_path.startswith(f"{token}/")
def _test_files() -> tuple[str, ...]:
return tuple(
sorted(
path.relative_to(REPO_ROOT).as_posix()
for path in TESTS_ROOT.rglob("test_*.py")
if path.is_file() and "node_modules" not in path.parts
)
)
def _dockerfiles() -> tuple[str, ...]:
return tuple(
sorted(
path.relative_to(REPO_ROOT).as_posix()
for path in REPO_ROOT.rglob("Dockerfile*")
if path.is_file()
and ".git" not in path.parts
and "node_modules" not in path.parts
and not path.name.endswith(".dockerignore")
)
)
def _uncovered_tests(allowlist: Allowlist, tokens: frozenset[str]) -> tuple[Finding, ...]:
uncovered = tuple(
relative_path
for relative_path in _test_files()
if not any(_token_covers(token, relative_path) for token in tokens) and not allowlist.covers_test(relative_path)
)
directories = tuple(dict.fromkeys(path.rsplit("/", 1)[0] for path in uncovered))
return tuple(
Finding(
subject=directory,
detail=_describe(tuple(p for p in uncovered if p.rsplit("/", 1)[0] == directory)),
)
for directory in directories
)
def _describe(paths: tuple[str, ...]) -> str:
names = ", ".join(path.rsplit("/", 1)[1] for path in paths[:3])
suffix = f", +{len(paths) - 3} more" if len(paths) > 3 else ""
return f"{len(paths)} test file(s) invoked by no job: {names}{suffix}"
def _uncovered_dockerfiles(allowlist: Allowlist, tokens: frozenset[str]) -> tuple[Finding, ...]:
return tuple(
Finding(subject=relative_path, detail="built by no job")
for relative_path in _dockerfiles()
if relative_path not in tokens and not allowlist.covers_dockerfile(relative_path)
)
def _parse_entry(item: object, section: str) -> AllowEntry:
if not isinstance(item, dict):
raise SystemExit(f"{ALLOWLIST_FILE.name}: '{section}' entries must be mappings")
paths = item.get("paths")
reason = item.get("reason")
if (
not isinstance(paths, list)
or not paths
or not all(isinstance(path, str) for path in paths)
or not isinstance(reason, str)
or not reason.strip()
):
raise SystemExit(
f"{ALLOWLIST_FILE.name}: every '{section}' entry needs a non-empty 'paths' "
"list of strings and a non-empty 'reason'"
)
return AllowEntry(paths=tuple(paths), reason=reason)
def _parse_entries(raw: object, section: str) -> tuple[AllowEntry, ...]:
if not isinstance(raw, list):
raise SystemExit(f"{ALLOWLIST_FILE.name}: '{section}' must be a list")
return tuple(_parse_entry(item, section) for item in raw)
def _load_allowlist() -> Allowlist:
if not ALLOWLIST_FILE.is_file():
return Allowlist(test_paths=(), dockerfiles=())
raw = yaml.safe_load(ALLOWLIST_FILE.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
raise SystemExit(f"{ALLOWLIST_FILE.name}: top level must be a mapping")
unknown = sorted(str(key) for key in raw if key not in ALLOWLIST_KEYS)
if unknown:
raise SystemExit(
f"{ALLOWLIST_FILE.name}: unknown top-level key(s) {unknown}; expected only {sorted(ALLOWLIST_KEYS)}"
)
return Allowlist(
test_paths=_parse_entries(raw.get("test_paths", []), "test_paths"),
dockerfiles=_parse_entries(raw.get("dockerfiles", []), "dockerfiles"),
)
def _write(message: str) -> None:
sys.stdout.write(f"{message}\n")
def _report(title: str, findings: tuple[Finding, ...], remedy: str) -> None:
_write(f"ERROR: {title}")
for finding in findings:
_write(f" - {finding.subject}: {finding.detail}")
_write("")
_write(remedy)
_write("")
def main() -> int:
allowlist = _load_allowlist()
scalars = _all_scalars()
test_findings = _uncovered_tests(allowlist, _invoked_test_tokens(scalars))
dockerfile_findings = _uncovered_dockerfiles(allowlist, _built_dockerfile_tokens(scalars))
if test_findings:
_report(
"test files that no CI job invokes",
test_findings,
"Add each to a job's test path, or list it in .github/ci-coverage-allowlist.yml with a reason.",
)
if dockerfile_findings:
_report(
"Dockerfiles that no CI job builds",
dockerfile_findings,
"Build each in a workflow, or list it in .github/ci-coverage-allowlist.yml with a reason.",
)
if test_findings or dockerfile_findings:
return 1
_write(
f"OK: {len(_test_files())} test files and {len(_dockerfiles())} Dockerfiles are each "
"invoked by at least one job or carry an explicit allowlist entry."
)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -154,6 +154,19 @@ jobs:
merge-multiple: true
- name: Upload to Codecov
id: codecov-upload
continue-on-error: true
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
with:
use_oidc: true
directory: coverage-reports
root_dir: ${{ github.workspace }}
flags: ${{ inputs.artifact-name }}
fail_ci_if_error: false
- name: Upload to Codecov (retry)
if: steps.codecov-upload.outcome == 'failure'
continue-on-error: true
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
with:
use_oidc: true

View file

@ -56,7 +56,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "20"
node-version-file: ui/litellm-dashboard/.nvmrc
cache: "npm"
cache-dependency-path: ui/litellm-dashboard/package-lock.json

42
.github/workflows/ci-coverage.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: "CI Coverage"
on:
pull_request:
branches:
- main
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
assert-ci-coverage:
name: assert-ci-coverage
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Assert every test file and Dockerfile is invoked by a job
run: |
python -m pip install "pyyaml==6.0.3"
python .github/scripts/assert_ci_coverage.py

View file

@ -13,35 +13,16 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false
- name: Create daily oss-agent-shin branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Configure Git user
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Generate branch name with MM_DD_YYYY format
BRANCH_NAME="litellm_oss_agent_shin_$(date +'%m_%d_%Y')"
echo "Creating branch: $BRANCH_NAME"
# Fetch all branches
git fetch --all
# Check if the branch already exists
if git show-ref --verify --quiet refs/remotes/origin/$BRANCH_NAME; then
if gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH_NAME" --silent 2>/dev/null; then
echo "Branch $BRANCH_NAME already exists. Skipping creation."
else
echo "Creating new branch: $BRANCH_NAME"
# Create the new branch from main
git checkout -b $BRANCH_NAME origin/main
# Push the new branch
git push origin $BRANCH_NAME
echo "Successfully created and pushed branch: $BRANCH_NAME"
exit 0
fi
MAIN_SHA=$(gh api "repos/${{ github.repository }}/git/ref/heads/main" --jq '.object.sha')
gh api "repos/${{ github.repository }}/git/refs" -f ref="refs/heads/$BRANCH_NAME" -f sha="$MAIN_SHA" --silent
echo "Successfully created branch: $BRANCH_NAME at $MAIN_SHA"

View file

@ -13,38 +13,19 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false
- name: Create daily staging branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Configure Git user
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Generate branch name with MM_DD_YYYY format
BRANCH_NAME="litellm_oss_staging_$(date +'%m_%d_%Y')"
echo "Creating branch: $BRANCH_NAME"
# Fetch all branches
git fetch --all
# Check if the branch already exists
if git show-ref --verify --quiet refs/remotes/origin/$BRANCH_NAME; then
if gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH_NAME" --silent 2>/dev/null; then
echo "Branch $BRANCH_NAME already exists. Skipping creation."
else
echo "Creating new branch: $BRANCH_NAME"
# Create the new branch from main
git checkout -b $BRANCH_NAME origin/main
# Push the new branch
git push origin $BRANCH_NAME
echo "Successfully created and pushed branch: $BRANCH_NAME"
exit 0
fi
MAIN_SHA=$(gh api "repos/${{ github.repository }}/git/ref/heads/main" --jq '.object.sha')
gh api "repos/${{ github.repository }}/git/refs" -f ref="refs/heads/$BRANCH_NAME" -f sha="$MAIN_SHA" --silent
echo "Successfully created branch: $BRANCH_NAME at $MAIN_SHA"
create-internal-dev-branch:
if: github.repository == 'BerriAI/litellm'
@ -53,35 +34,16 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
persist-credentials: false
- name: Create internal dev branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Configure Git user
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Generate branch name with MM_DD_YYYY format
BRANCH_NAME="litellm_internal_dev_$(date +'%m_%d_%Y')"
echo "Creating branch: $BRANCH_NAME"
# Fetch all branches
git fetch --all
# Check if the branch already exists
if git show-ref --verify --quiet refs/remotes/origin/$BRANCH_NAME; then
if gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH_NAME" --silent 2>/dev/null; then
echo "Branch $BRANCH_NAME already exists. Skipping creation."
else
echo "Creating new branch: $BRANCH_NAME"
# Create the new branch from main
git checkout -b $BRANCH_NAME origin/main
# Push the new branch
git push origin $BRANCH_NAME
echo "Successfully created and pushed branch: $BRANCH_NAME"
exit 0
fi
MAIN_SHA=$(gh api "repos/${{ github.repository }}/git/ref/heads/main" --jq '.object.sha')
gh api "repos/${{ github.repository }}/git/refs" -f ref="refs/heads/$BRANCH_NAME" -f sha="$MAIN_SHA" --silent
echo "Successfully created branch: $BRANCH_NAME at $MAIN_SHA"

View file

@ -23,21 +23,28 @@ jobs:
with:
version: "3.11.1"
- name: Download and verify Helm Unit Test Plugin
run: |
curl -fsSLo "$RUNNER_TEMP/helm-unittest.tgz" https://github.com/helm-unittest/helm-unittest/releases/download/v0.8.2/helm-unittest-linux-amd64-0.8.2.tgz
echo "56ab3091e6fa52a7c92ee951def9bed957f295d9ce98483aed404e748d7b3a94 $RUNNER_TEMP/helm-unittest.tgz" | sha256sum -c -
- name: Install Helm Unit Test Plugin
run: |
helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.4.4
- name: Verify Helm Unit Test Plugin integrity
run: |
EXPECTED_SHA="e251ba198448629678ff2168e1a469249d998155"
PLUGIN_DIR="$(helm env HELM_PLUGINS)/helm-unittest"
ACTUAL_SHA="$(git -C "$PLUGIN_DIR" rev-parse HEAD)"
if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
echo "::error::Helm unittest plugin checksum mismatch! Expected $EXPECTED_SHA but got $ACTUAL_SHA"
exit 1
fi
echo "Helm unittest plugin integrity verified: $ACTUAL_SHA"
mkdir -p "$PLUGIN_DIR"
tar -xzf "$RUNNER_TEMP/helm-unittest.tgz" -C "$PLUGIN_DIR"
helm plugin list
- name: Run unit tests
run: |
helm unittest -f 'tests/*.yaml' helm/litellm-helm
helm unittest -f 'tests/*.yaml' helm/litellm
for chart in helm/litellm-helm helm/litellm; do
declared="$(grep -h '^suite:' "$chart"/tests/*.yaml | wc -l | tr -d '[:space:]')"
output="$(mktemp)"
helm unittest -f 'tests/*.yaml' "$chart" | tee "$output"
executed="$(sed -n 's/^Test Suites:.*[[:space:]]\([0-9][0-9]*\) total$/\1/p' "$output")"
if [ "$declared" != "$executed" ]; then
echo "::error::$chart declares $declared test suites but helm-unittest ran $executed. Suites are being skipped silently, so their assertions never execute."
exit 1
fi
echo "$chart: all $declared declared test suites ran"
done

View file

@ -8,8 +8,12 @@ on:
- litellm_oss_branch
- "litellm_**"
paths:
- Dockerfile
- docker/Dockerfile.non_root
- tests/proxy_migration_tests/test_offline_image_migration.py
- migrations/Dockerfile
- migrations/run.py
- litellm-proxy-extras/**
- tests/proxy_migration_tests/**
- uv.lock
- ui/litellm-dashboard/package-lock.json
- .github/workflows/image-scan.yml
@ -83,3 +87,63 @@ jobs:
--only-fixed \
--fail-on high \
--output table
runtime-image:
name: runtime-image
runs-on: ubuntu-latest
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Build runtime image
run: docker build -f Dockerfile -t litellm-runtime-scan:${{ github.sha }} .
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify offline migration as a non-root uid
env:
LITELLM_IMAGE: litellm-runtime-scan:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
migrations-image:
name: migrations-image
runs-on: ubuntu-latest
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Build migrations image
run: docker build -f migrations/Dockerfile -t litellm-migrations-scan:${{ github.sha }} .
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify offline migration as a non-root uid
env:
LITELLM_IMAGE: litellm-migrations-scan:${{ github.sha }}
LITELLM_MIGRATION_INTERPRETER: python3
LITELLM_MIGRATION_SCRIPT: /app/run.py
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v

View file

@ -0,0 +1,62 @@
name: Publish basedpyright base counts
# Every commit on litellm_internal_staging is some branch's future merge-base.
# Publishing its per-rule basedpyright counts as an artifact lets
# scripts/type_check_gate.py download them in seconds instead of paying a
# 60-110s second basedpyright pass on every fresh worktree or moved merge-base.
# No concurrency group on purpose: runs must never cancel each other, because
# every sha's artifact matters (any of them can become a merge-base).
on:
push:
branches:
- litellm_internal_staging
workflow_dispatch:
inputs:
ref:
description: "Ref to compute and publish base counts for"
required: false
default: litellm_internal_staging
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
ref: ${{ inputs.ref || github.sha }}
clean: true
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up uv
uses: ./.github/actions/setup-uv-with-retries
with:
version: "0.10.9"
# The gate provisions its own measurement env (.venv-typecheck: a frozen
# uv sync of its canonical dependency groups plus a generated Prisma
# client), so no install step here can drift from what local runs measure.
- name: Emit basedpyright counts for HEAD
env:
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
run: |
python scripts/type_check_gate.py --emit-counts-dir "$RUNNER_TEMP/basedpyright-counts"
counts_file=$(ls "$RUNNER_TEMP"/basedpyright-counts/basedpyright-counts-*.json)
echo "COUNTS_ARTIFACT_NAME=$(basename "$counts_file" .json)" >> "$GITHUB_ENV"
- name: Upload counts artifact
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: ${{ env.COUNTS_ARTIFACT_NAME }}
path: ${{ runner.temp }}/basedpyright-counts/
if-no-files-found: error

View file

@ -7,13 +7,17 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
code-quality:

View file

@ -15,6 +15,12 @@ jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 15
# actions: read lets scripts/type_check_gate.py download the base-counts
# artifact published by publish-basedpyright-base-counts.yml instead of
# re-running basedpyright over the merge-base tree.
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
@ -23,10 +29,21 @@ jobs:
# Any-discipline) would otherwise blame on this branch.
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
fetch-depth: 1
clean: true
persist-credentials: false
- name: Fetch gate base (merge-base with target branch)
env:
GH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
MERGE_BASE=$(gh api "repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
test -n "$MERGE_BASE"
git fetch --no-tags --depth=1 origin "$MERGE_BASE"
echo "GATE_BASE_SHA=$MERGE_BASE" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
@ -60,10 +77,8 @@ jobs:
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Check ruff format
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- 'litellm/**/*.py' | grep -v '^litellm/enterprise/' > "$RUNNER_TEMP/ruff_format_files.txt" || true
git diff --name-only --diff-filter=ACMR "$GATE_BASE_SHA" HEAD -- 'litellm/**/*.py' | grep -v '^litellm/enterprise/' > "$RUNNER_TEMP/ruff_format_files.txt" || true
if [ ! -s "$RUNNER_TEMP/ruff_format_files.txt" ]; then
echo "No changed litellm Python files to check with ruff format."
exit 0
@ -86,16 +101,12 @@ jobs:
cd ..
- name: Check strict-rule budget (delta vs base)
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
uv run --no-sync python scripts/ruff_strict_gate.py --base "$BASE_SHA"
uv run --no-sync python scripts/ruff_strict_gate.py --base "$GATE_BASE_SHA"
- name: Check type-discipline budget (mutable collections / casts / type guards / kwargs / unexplained suppressions, delta vs base)
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
uv run --no-sync python scripts/type_discipline_gate.py --base "$BASE_SHA"
uv run --no-sync python scripts/type_discipline_gate.py --base "$GATE_BASE_SHA"
- name: Print OpenAI version
run: |
@ -103,16 +114,14 @@ jobs:
- name: Check basedpyright budget (delta vs base)
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
NODE_OPTIONS: --max-old-space-size=12288
GH_TOKEN: ${{ github.token }}
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
run: |
(uv run --no-sync basedpyright --outputjson || true) | uv run --no-sync python scripts/type_check_gate.py --base "$BASE_SHA"
uv run --no-sync python scripts/type_check_gate.py --base "$GATE_BASE_SHA"
- name: Check tests/e2e basedpyright (zero errors)
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if git diff --name-only --diff-filter=ACMRD "$BASE_SHA"...HEAD -- 'tests/e2e/**/*.py' | grep -q .; then
if git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- 'tests/e2e/**/*.py' | grep -q .; then
uv run --no-sync basedpyright tests/e2e
else
echo "No changed tests/e2e Python files; skipping."
@ -141,9 +150,15 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
fetch-depth: 1
persist-credentials: false
- name: Fetch ratchet base
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git fetch --no-tags --depth=1 origin "$BASE_SHA"
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
@ -164,7 +179,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
fetch-depth: 1
persist-credentials: false
- name: Set up Python
@ -179,13 +194,14 @@ jobs:
- name: Run secret scan test
run: |
uv run --frozen --with 'pytest==9.0.2' pytest tests/litellm/test_no_hardcoded_secrets.py -v
uv run --no-project --with 'pytest==9.0.2' pytest tests/litellm/test_no_hardcoded_secrets.py -v
- name: Run ggshield secret scan
env:
GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }}
run: |
if [ -n "$GITGUARDIAN_API_KEY" ]; then
git fetch --no-tags --unshallow origin
uv tool run --from 'ggshield==1.48.0' ggshield secret scan repo .
else
echo "GITGUARDIAN_API_KEY not set, skipping ggshield scan"

View file

@ -27,7 +27,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "20"
node-version-file: ui/litellm-dashboard/.nvmrc
cache: "npm"
cache-dependency-path: ui/litellm-dashboard/package-lock.json

View file

@ -22,12 +22,13 @@ jobs:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
fetch-depth: 1
persist-credentials: false
- name: Collect changed files
id: changed
env:
GH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
@ -37,7 +38,9 @@ jobs:
# landed since, so a PR that touches no UI file still gets linted
# against hundreds of other people's files. Diff the PR head against its
# own merge base instead, which is exactly what this PR changed.
merge_base=$(git merge-base "$BASE_SHA" "$HEAD_SHA")
merge_base=$(gh api "repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
test -n "$merge_base"
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
: > "$RUNNER_TEMP/prettier_files.txt"
: > "$RUNNER_TEMP/eslint_files.txt"
while IFS= read -r f; do
@ -61,7 +64,7 @@ jobs:
if: steps.changed.outputs.has_files == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "20"
node-version-file: ui/litellm-dashboard/.nvmrc
cache: "npm"
cache-dependency-path: ui/litellm-dashboard/package-lock.json

View file

@ -29,13 +29,13 @@ jobs:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
fetch-depth: 1
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "20"
node-version-file: ui/litellm-dashboard/.nvmrc
cache: "npm"
cache-dependency-path: ui/litellm-dashboard/package-lock.json
@ -45,11 +45,24 @@ jobs:
- name: Run UI unit tests (Vitest)
env:
CI: "true"
GH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ -n "$BASE_SHA" ]; then
echo "Pull request: running only tests related to changes since $BASE_SHA"
npm run test -- --run --changed "$BASE_SHA" --passWithNoTests \
merge_base=$(gh api "repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
test -n "$merge_base"
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
changed_files=()
while IFS= read -r f; do
changed_files+=("$f")
done < <(git diff --name-only --relative "$merge_base" "$HEAD_SHA" -- .)
if [ ${#changed_files[@]} -eq 0 ]; then
echo "No UI files changed in this PR; skipping unit tests."
exit 0
fi
echo "Pull request: running tests related to ${#changed_files[@]} changed UI files"
npm run test -- related "${changed_files[@]}" --run --passWithNoTests \
--pool forks --poolOptions.forks.maxForks=14
else
echo "Push to $GITHUB_REF_NAME: running the full suite"

View file

@ -7,6 +7,10 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
@ -14,8 +18,8 @@ permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
core-utils:

View file

@ -7,13 +7,17 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
documentation:

View file

@ -7,6 +7,10 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
@ -14,8 +18,8 @@ permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
enterprise-routing:

View file

@ -7,6 +7,10 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
@ -14,8 +18,8 @@ permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
integrations:

View file

@ -7,13 +7,17 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
vertex-ai:

View file

@ -7,6 +7,10 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
@ -14,8 +18,8 @@ permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
misc:
@ -36,7 +40,11 @@ jobs:
tests/test_litellm/interactions
tests/test_litellm/ocr
tests/test_litellm/passthrough
tests/test_litellm/rag
tests/test_litellm/realtime_api
tests/test_litellm/rerank_api
tests/test_litellm/sandbox
tests/test_litellm/test_router
tests/test_litellm/vector_stores
tests/test_litellm/videos
tests/test_litellm/test_*.py

View file

@ -7,6 +7,10 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
@ -14,8 +18,8 @@ permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
proxy-auth:

View file

@ -7,13 +7,17 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Semantic matrix: each shard groups tests by concern (auth, server, logging, …)
# rather than alphabetical letter ranges. Adding a new test file means adding it

View file

@ -7,14 +7,18 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
proxy-endpoints:
@ -25,7 +29,9 @@ jobs:
uses: ./.github/workflows/_test-unit-base.yml
with:
test-path: >-
tests/test_litellm/proxy/analytics_endpoints
tests/test_litellm/proxy/management_endpoints
tests/test_litellm/proxy/memory
tests/test_litellm/proxy/guardrails
tests/test_litellm/proxy/management_helpers
tests/test_litellm/proxy/anthropic_endpoints

View file

@ -7,6 +7,10 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
@ -14,8 +18,8 @@ permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
proxy-infra:
@ -29,6 +33,8 @@ jobs:
tests/test_litellm/proxy/_experimental
tests/test_litellm/proxy/experimental
tests/test_litellm/proxy/common_utils
tests/test_litellm/proxy/enterprise_billing
tests/test_litellm/proxy/types_utils
tests/test_litellm/proxy/logging_endpoints
tests/test_litellm/proxy/test_*.py
workers: 2

View file

@ -7,13 +7,17 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:

View file

@ -7,6 +7,10 @@ on:
- litellm_internal_staging
- litellm_oss_staging
- "litellm_**"
push:
branches:
- main
- litellm_internal_staging
permissions:
contents: read
@ -14,8 +18,8 @@ permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
responses-caching-types:

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
.python-version
.venv
.venv-typecheck
.venv_policy_test
.env
.claude

View file

@ -29,7 +29,7 @@ Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We pref
If you ever make public-facing PR descriptions, comments, issues, commit messages, etc., always follow these guidelines to sound less AI-y:
- don't use emojis
- don't use "—". Instead, reach for ";", ".", etc.
- don't use "—". Instead, reach for ",", ".", conjunction words, ":", ";", etc. in descending order of preference: vary among them, weighted toward the front of the list, and skip "," where it would cause a comma splice or the sentence is getting long. Overusing any one of them, ";" especially, also feels AI-y. A word cap does not penalize you for adding more sentences: when writing under tight word budgets, prefer a period split or a conjunction over ";", and keep to at most one ";" per message
- don't use the pattern "It's not X, it's Y", "You're not X, you're Y", etc.
- don't use bulleted or numbered lists unless it would be nonsensical not to. Instead, prefer prose
- don't add a trailing "." at the end of paragraphs (just like this file). That means every paragraph, not just the last one (of the markdown file, PR description, GitHub comment, etc.). Rule of thumb: if you're adding new line(s) before the next sentence, don't add a "."
@ -39,15 +39,13 @@ Don't hesitate to use values in .env to get needed API keys and other secrets, a
Python max line length is 120, not 88
On a fresh worktree or clone, run `make bootstrap` before anything else. It provisions everything tests, `make pre-commit`, and a local proxy need
Run tests before you commit. Also, run `make pre-commit` right before each commit, which generates types (as needed) and formats/lints your code. Any errors found must be fixed. It only runs when there are staged frontend and/or backend changes and calculates violations, generates types, etc. based on the worktree, so stage what you need or stash/delete unwanted files in litellm/ or ui/ (where backend and frontend lint run, respectively) before running it. If it fails because dashboard api types are stale, it already regenerated them for you. You just need to stage the schema.d.ts, re-run `make pre-commit` to confirm it passes, and commit
When you fix violations gated by `ruff-strict-budget.json`, `type-discipline-budget.json`, or `basedpyright-code-budget.json`, run `make lint-budget-update` and commit the lowered limits so the ceilings ratchet down instead of leaving stale headroom. It measures the working tree, so it must contain exactly the fixes you're committing
`make pre-commit` saves its complete output to a log file in .git (overwriting previous pre-commit logs) and prints that path as its first and last output lines. To inspect a run, read or grep that log instead of re-running the multi-minute checks just to see a different slice
If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and pushing `reportAny` / `reportExplicitAny` closer to their basedpyright ceilings, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in
If you get an LIT001 or LIT002 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally `# mutable-ok` is never used; reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason
If you get an LIT001 or LIT002 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `MappingProxyType()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally, `# mutable-ok` is never used; reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason
Every lint or type suppression must name the exact rule inside brackets and carry a reason comment, e.g. `# pyright: ignore[reportArgumentType] # stubs lack async overload` or `# noqa: TID251 # <reason>`. `# type: ignore` is banned (LIT009): pyrightconfig.json sets `enableTypeIgnoreComments` to false, so it silently does nothing
@ -61,7 +59,7 @@ Do not add `Co-Authored-By: Claude` or any Claude attribution to commit messages
When working on a PR, keep the PR description in sync with new commits being made
Replies/rebuttals to AI PR review bots must be 15-25 word human-readable replies
All GitHub comments must be human-readable and 15-25 words max
Monkeypatching attributes of a class to do testing is an anti-pattern. Prefer dependency-injecting things into classes. That way, at unit test time, you can pass a mocked dependency in
@ -74,7 +72,8 @@ Follow these coding conventions for new/updated code (a three-line fix in a lega
- Composition over inheritance
- Never-nester: early returns over deep nesting
- Don't throw; model failures as values (One function (e.g., raise_public) maps error union to existing public exception contracts via exhaustive match + assert_never)
- No mutation; don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses (with slots=True), etc.
- No mutation; don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses (with slots=True), `MappingProxyType`, etc.
- Annotate every variable with `: Final` (LIT010). Unpacking and walrus targets cannot carry the annotation, so they are implicitly final. Don't rebind them. Never rebind or mutate function parameters (LIT011); `self`/`cls` attribute stores are the exception. If rebinding or in-place mutation is truly unavoidable, suppress with `# rebind-ok: <reason>` explaining why
- Use dependency injection
- Fully typed; no `Any` or coarse types like `dict[str, Any]` or just `dict`. Every function parameter must be strongly typed
- Use tagged unions + match

View file

@ -7,7 +7,7 @@ ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
# Pinned by digest like the other base images; bump explicitly on Node upgrades.
ARG UI_BUILD_IMAGE=node:20.18-alpine3.20@sha256:3488b10bf958af7125a176419d2d8a9937d895bf124012aae811651988d2ffe6
ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
FROM $UV_IMAGE AS uvbin
@ -134,7 +134,8 @@ RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \
find /app/.venv -type d -path "*/tornado/test" -delete && \
chmod -R a+rX /opt/prisma && \
test -x /opt/prisma/binaries/node_modules/.bin/prisma && \
test -f /opt/prisma/binaries/node_modules/prisma/build/index.js
test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \
python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths"
EXPOSE 4000/tcp

View file

@ -75,7 +75,7 @@ install-dev:
bootstrap:
$(UV) sync --inexact --frozen --extra proxy --group proxy-dev --group e2e-dev
$(UV_RUN) python scripts/prisma_generate_if_needed.py
cd ui/litellm-dashboard && npm ci --no-audit --no-fund
cd ui/litellm-dashboard && ../../scripts/with_dashboard_node.sh npm install --no-audit --no-fund
@main_root=$$(git worktree list --porcelain | head -1 | sed 's/^worktree //'); \
if [ "$$main_root" != "$$(git rev-parse --show-toplevel)" ] && [ -f "$$main_root/.env" ] && [ ! -f .env ]; then \
cp "$$main_root/.env" .env && echo "bootstrap: copied .env from $$main_root"; \
@ -99,7 +99,10 @@ install-test-deps: install-proxy-dev
$(UV_RUN) prisma generate --schema litellm/proxy/schema.prisma
install-helm-unittest:
helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.4.4 || echo "ignore error if plugin exists"
@helm plugin list | grep -qE '^unittest[[:space:]]+0\.8\.2([[:space:]]|$$)' || { \
helm plugin uninstall unittest >/dev/null 2>&1 || true; \
helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.8.2; \
}
# Install git hooks that enforce Conventional Commits and Conventional Branches.
# Opt-in: not chained into install-dev.
@ -121,10 +124,10 @@ lint-fetch-base:
git fetch origin litellm_internal_staging
# Mirror test-linting.yml's lint job environment: the proxy-dev group plus a generated
# Prisma client, so basedpyright resolves the same modules CI does (without the generated
# client the DB wrappers typed against it degrade to Unknown, drifting the budget from
# CI's). --inexact tops up the venv instead of pruning the proxy extras gen:api and the
# running proxy need.
# Prisma client, so `basedpyright tests/e2e` resolves the same modules CI does. The
# budget gate itself no longer measures here (scripts/type_check_gate.py provisions its
# own .venv-typecheck). --inexact tops up the venv instead of pruning the proxy extras
# gen:api and the running proxy need.
lint-install:
$(UV) sync --inexact --frozen --group proxy-dev --group e2e-dev
$(UV_RUN) python scripts/prisma_generate_if_needed.py
@ -176,10 +179,8 @@ lint-ruff-FULL-dev: install-dev
if [ -n "$$files" ]; then echo "$$files" | xargs $(UV_RUN) ruff check; \
else echo "No changed .py files to check."; fi
lint-basedpyright lint-basedpyright-budget-update: export NODE_OPTIONS := --max-old-space-size=12288
lint-basedpyright: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --base origin/litellm_internal_staging
$(UV_RUN) python scripts/type_check_gate.py --base origin/litellm_internal_staging
lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL)
$(UV_RUN) basedpyright tests/e2e
@ -192,7 +193,7 @@ lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
# --update lowers each limit by what this branch fixed since its branch point, so
# it needs the base ref fetched to resolve the merge-base.
lint-basedpyright-budget-update: install-dev lint-fetch-base
($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --update
$(UV_RUN) python scripts/type_check_gate.py --update
lint-format: format-check
@ -239,7 +240,7 @@ lint-dev: lint-format-changed check-circular-imports check-import-safety
# test-linting.yml (Python), test-litellm-ui-build.yml's frontend-lint (dashboard), and
# check-ui-api-types.yml (API-type drift), skipping any whose files you didn't stage.
# Not auto-installed as a git hook so it never slows an unrelated human commit.
pre-commit:
pre-commit: bootstrap
./scripts/pre_commit_lint.sh
# Testing targets

View file

@ -59,9 +59,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--extra semantic-router \
--python python3
RUN mkdir -p /home/nonroot && \
HOME=/home/nonroot prisma generate --schema=./schema.prisma && \
chown -R nonroot:nonroot /home/nonroot/.cache
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
npm_config_cache=/root/.npm \
prisma generate --schema=./schema.prisma
RUN sed -i 's/\r$//' docker/component_entrypoint.sh && chmod +x docker/component_entrypoint.sh
# ---------- Runtime ----------
FROM $LITELLM_RUNTIME_IMAGE AS runtime
@ -81,17 +83,20 @@ ENV HOME=/home/nonroot \
PATH="/app/.venv/bin:${PATH}" \
PYTHONPATH="/app" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1 \
PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries
COPY --from=builder --chown=nonroot:nonroot /app /app
COPY --from=builder --chown=nonroot:nonroot /home/nonroot/.cache /home/nonroot/.cache
COPY --from=builder /opt/prisma /opt/prisma
RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \
find /app/.venv -type d -path "*/tornado/test" -delete
find /app/.venv -type d -path "*/tornado/test" -delete && \
chmod -R a+rX /opt/prisma && \
python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths"
USER nonroot
EXPOSE 4001/tcp
ENTRYPOINT ["uvicorn", "backend.main:app"]
ENTRYPOINT ["/app/docker/component_entrypoint.sh", "uvicorn", "backend.main:app"]
CMD ["--host", "0.0.0.0", "--port", "4001"]

View file

@ -44,6 +44,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/router/",
"/router_settings",
"/adaptive_router/",
"/auto_router/",
"/fallback",
"/fallbacks",
"/cache_settings",
@ -81,6 +82,9 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/user_agent",
"/usage/",
"/daily/",
# Deployment-wide gateway request counts. Scoped to the analytics read rather
# than all of /gateway/, which stays free for data-plane routes.
"/gateway/daily/",
# CloudZero cost-export admin (init / settings / export / dry-run / delete)
"/cloudzero/",
# Caching admin

View file

@ -1,66 +1,66 @@
{
"reportAny": {
"limit": 31903
"limit": 28842
},
"reportArgumentType": {
"limit": 2645
"limit": 2634
},
"reportAssignmentType": {
"limit": 329
},
"reportAttributeAccessIssue": {
"limit": 516
"limit": 514
},
"reportCallIssue": {
"limit": 123
"limit": 117
},
"reportConstantRedefinition": {
"limit": 59
"limit": 40
},
"reportDeprecated": {
"limit": 325
"limit": 215
},
"reportDuplicateImport": {
"limit": 42
"limit": 19
},
"reportExplicitAny": {
"limit": 10214
"limit": 9103
},
"reportFunctionMemberAccess": {
"limit": 11
"limit": 7
},
"reportGeneralTypeIssues": {
"limit": 227
"limit": 157
},
"reportIncompatibleMethodOverride": {
"limit": 78
"limit": 56
},
"reportIncompatibleVariableOverride": {
"limit": 12
"limit": 8
},
"reportInconsistentOverload": {
"limit": 18
"limit": 12
},
"reportIndexIssue": {
"limit": 37
"limit": 35
},
"reportInvalidTypeForm": {
"limit": 35
},
"reportInvalidTypeVarUse": {
"limit": 5
"limit": 2
},
"reportMatchNotExhaustive": {
"limit": 0
},
"reportMissingParameterType": {
"limit": 5869
"limit": 5843
},
"reportMissingTypeArgument": {
"limit": 15861
"limit": 15816
},
"reportMissingTypeStubs": {
"limit": 41
"limit": 40
},
"reportOperatorIssue": {
"limit": 0
@ -72,7 +72,7 @@
"limit": 0
},
"reportOptionalMemberAccess": {
"limit": 1079
"limit": 1078
},
"reportOptionalOperand": {
"limit": 0
@ -81,16 +81,16 @@
"limit": 0
},
"reportPossiblyUnboundVariable": {
"limit": 77
"limit": 56
},
"reportPrivateUsage": {
"limit": 2437
"limit": 1825
},
"reportRedeclaration": {
"limit": 12
"limit": 8
},
"reportReturnType": {
"limit": 219
"limit": 218
},
"reportTypedDictNotRequiredAccess": {
"limit": 27
@ -99,48 +99,48 @@
"limit": 0
},
"reportUnknownArgumentType": {
"limit": 45366
"limit": 45110
},
"reportUnknownLambdaType": {
"limit": 113
},
"reportUnknownMemberType": {
"limit": 40477
"limit": 39838
},
"reportUnknownParameterType": {
"limit": 20338
"limit": 20237
},
"reportUnknownVariableType": {
"limit": 32047
"limit": 31383
},
"reportUnnecessaryCast": {
"limit": 177
"limit": 122
},
"reportUnnecessaryComparison": {
"limit": 1021
"limit": 701
},
"reportUnnecessaryContains": {
"limit": 7
"limit": 5
},
"reportUnnecessaryIsInstance": {
"limit": 1205
"limit": 864
},
"reportUntypedBaseClass": {
"limit": 165
"limit": 0
},
"reportUntypedFunctionDecorator": {
"limit": 33
},
"reportUnusedClass": {
"limit": 33
"limit": 23
},
"reportUnusedFunction": {
"limit": 204
"limit": 139
},
"reportUnusedImport": {
"limit": 1003
"limit": 555
},
"reportUnusedVariable": {
"limit": 1297
"limit": 146
}
}

View file

@ -7,7 +7,7 @@ ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b17a5ee8bc23b532600a44d705acef2409e0933c1251b45f
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
# Pinned by digest like the other base images; bump explicitly on Node upgrades.
ARG UI_BUILD_IMAGE=node:20.18-alpine3.20@sha256:3488b10bf958af7125a176419d2d8a9937d895bf124012aae811651988d2ffe6
ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
FROM $UV_IMAGE AS uvbin
@ -133,7 +133,8 @@ RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \
find /app/.venv -type d -path "*/tornado/test" -delete && \
chmod -R a+rX /opt/prisma && \
test -x /opt/prisma/binaries/node_modules/.bin/prisma && \
test -f /opt/prisma/binaries/node_modules/prisma/build/index.js
test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \
python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths"
EXPOSE 4000/tcp

View file

@ -6,7 +6,7 @@ ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:42df77a9974d6ec8b
ARG PROXY_EXTRAS_SOURCE=published
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
# Pinned by digest like the other base images; bump explicitly on Node upgrades.
ARG UI_BUILD_IMAGE=node:20.18-alpine3.20@sha256:3488b10bf958af7125a176419d2d8a9937d895bf124012aae811651988d2ffe6
ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
FROM $UV_IMAGE AS uvbin
@ -185,7 +185,8 @@ RUN mkdir -p /nonexistent /app/.cache /var/lib/litellm/assets /var/lib/litellm/u
chmod -R a+rX /opt/prisma && \
test -x /opt/prisma/binaries/node_modules/.bin/prisma && \
test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \
ls /opt/prisma/binaries/node_modules/@prisma/engines/query-engine-* >/dev/null 2>&1
ls /opt/prisma/binaries/node_modules/@prisma/engines/query-engine-* >/dev/null 2>&1 && \
python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths"
USER 65534

View file

@ -54,8 +54,9 @@ else
fi || { echo "nvm checksum verification failed"; exit 1; }
bash "$NVM_SCRIPT"
source ~/.nvm/nvm.sh
nvm install v18.17.0
nvm use v18.17.0
NODE_VERSION="$(cat ui/litellm-dashboard/.nvmrc)"
nvm install "v${NODE_VERSION}"
nvm use "v${NODE_VERSION}"
# cd in to /ui/litellm-dashboard

View file

@ -47,7 +47,13 @@ RUN uv venv --python python && \
"prisma==0.11.0" \
"openai==2.24.0"
RUN prisma generate --schema=./schema.prisma
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
npm_config_cache=/root/.npm \
prisma generate --schema=./schema.prisma && \
chmod -R a+rX /opt/prisma && \
python -c "import sys; from prisma.client import BINARY_PATHS; bad = sorted(p for group in BINARY_PATHS.model_dump().values() for p in group.values() if not p.startswith('/opt/prisma/')); sys.exit('prisma engines baked outside /opt/prisma: %r' % bad) if bad else None"
ENV PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries
EXPOSE 4000/tcp

8
docker/component_entrypoint.sh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/sh
if [ "$USE_DDTRACE" = "true" ]; then
export DD_TRACE_OPENAI_ENABLED="False"
exec ddtrace-run "$@"
fi
exec "$@"

View file

@ -17,6 +17,7 @@ if TYPE_CHECKING:
from litellm.proxy._types import LiteLLM_ManagedObjectTable
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.router import Router
from litellm.types.router import Deployment
from litellm.types.utils import LiteLLMBatch
@ -281,6 +282,28 @@ class CheckBatchCost:
return deployment_id
return None
@classmethod
def _get_managed_file_model_name(
cls,
job: "LiteLLM_ManagedObjectTable",
deployment_info: "Deployment",
) -> Optional[str]:
"""
Public model group name to encode as ``target_model_names`` on unified output file ids.
Key model-access checks resolve a managed file id back to a model via its
``target_model_names``, so this must be the model group the caller requested, never the
underlying provider model (e.g. ``gpt-5.5``), which no key is allowed to call.
"""
from litellm.proxy.openai_files_endpoints.common_utils import (
resolve_managed_output_file_model_name,
)
return resolve_managed_output_file_model_name(
unified_input_file_id=cls._get_input_file_id(job),
fallback_model_name=deployment_info.model_name or None,
)
@staticmethod
def _get_input_file_id(job: "LiteLLM_ManagedObjectTable") -> Optional[str]:
import json
@ -406,6 +429,10 @@ class CheckBatchCost:
managed_files_hook = self.proxy_logging_obj.get_proxy_hook("managed_files")
if managed_files_hook is not None:
from litellm.proxy._types import UserAPIKeyAuth
managed_file_model_name = self._get_managed_file_model_name(
job=job, deployment_info=deployment_info
)
_minimal_auth = UserAPIKeyAuth(
user_id=job.created_by or "default-user-id",
team_id=getattr(job, "team_id", None),
@ -417,7 +444,7 @@ class CheckBatchCost:
_unified_file_id = managed_files_hook.get_unified_output_file_id(
output_file_id=_raw_file_id,
model_id=model_id,
model_name=str(model_name) if model_name else deployment_info.model_name or None,
model_name=managed_file_model_name,
)
await managed_files_hook.store_unified_file_id(
file_id=_unified_file_id,
@ -471,6 +498,7 @@ class CheckBatchCost:
},
"metadata": {
"user_api_key_user_id": creator_user_id,
"user_api_key_team_id": getattr(job, "team_id", None),
**user_info,
},
},
@ -629,6 +657,20 @@ class CheckBatchCost:
elif response.status in ("failed", "expired", "cancelled"):
try:
from litellm.proxy.openai_files_endpoints.common_utils import (
_is_base64_encoded_unified_file_id,
ensure_batch_response_managed_file_ids,
)
response.id = job.unified_object_id
await ensure_batch_response_managed_file_ids(
response=response,
managed_files_obj=self.proxy_logging_obj.get_proxy_hook("managed_files"),
prisma_client=self.prisma_client,
verbose_proxy_logger=verbose_proxy_logger,
db_batch_object=job,
unified_batch_id=_is_base64_encoded_unified_file_id(job.unified_object_id),
)
update_data = {
"status": response.status,
"file_object": response.model_dump_json(),

View file

@ -1,10 +1,10 @@
"""
Polls LiteLLM_ManagedObjectTable to check if the response is complete.
Cost tracking is handled automatically by litellm.aget_responses().
Cost tracking is handled automatically by the get-responses call.
"""
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Dict, Optional, cast
import litellm
from litellm._logging import verbose_proxy_logger
@ -13,11 +13,15 @@ from litellm.constants import (
MAX_OBJECTS_PER_POLL_CYCLE,
STALE_OBJECT_CLEANUP_BATCH_SIZE,
)
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.types.llms.openai import ResponsesAPIResponse
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.router import Router
TERMINAL_RESPONSE_STATUSES = frozenset({"completed", "failed", "cancelled", "incomplete"})
class CheckResponsesCost:
def __init__(
@ -33,6 +37,28 @@ class CheckResponsesCost:
self.prisma_client: PrismaClient = prisma_client
self.llm_router: Router = llm_router
async def _get_response(
self,
response_id: str,
litellm_metadata: Dict[str, str],
) -> ResponsesAPIResponse:
"""Fetch the upstream response, using deployment credentials when available.
LiteLLM-encoded response IDs carry the ``model_id`` of the deployment that
served the original request, so routing through ``llm_router`` applies that
deployment's ``api_base`` / ``api_key`` / ``api_version``, exactly like
``GET /v1/responses/{id}`` does. ``litellm.aget_responses`` on its own only
sees provider env vars, so it fails for every deployment whose credentials
live in the config; the row then never leaves ``queued``.
"""
model_id: Optional[str] = ResponsesAPIRequestUtils.get_model_id_from_response_id(response_id)
if model_id is None or self.llm_router.get_deployment(model_id=model_id) is None:
return await litellm.aget_responses(response_id=response_id, litellm_metadata=litellm_metadata)
router_response = await self.llm_router.aget_responses(
response_id=response_id, litellm_metadata=litellm_metadata
)
return cast(ResponsesAPIResponse, router_response)
async def _expire_stale_rows(
self, cutoff: datetime, batch_size: int
) -> int:
@ -87,8 +113,8 @@ class CheckResponsesCost:
Check if background responses are complete and track their cost.
- Get all status="queued" or "in_progress" and file_purpose="response" jobs
- Query the provider to check if response is complete
- Cost is automatically tracked by litellm.aget_responses()
- Mark completed/failed/cancelled responses as complete in the database
- Cost is automatically tracked by the get-responses call
- Mark responses in a terminal state as complete in the database
"""
try:
await self._cleanup_stale_managed_objects()
@ -134,7 +160,7 @@ class CheckResponsesCost:
litellm_metadata["model"] = model_name
litellm_metadata["model_group"] = model_name # Use same value for model_group
response = await litellm.aget_responses(
response = await self._get_response(
response_id=responses_id_security,
litellm_metadata=litellm_metadata,
)
@ -144,21 +170,14 @@ class CheckResponsesCost:
)
except Exception as e:
verbose_proxy_logger.info(
verbose_proxy_logger.warning(
f"Skipping job {unified_object_id} due to error: {e}"
)
continue
# Check if response is in a terminal state
if response.status == "completed":
if response.status in TERMINAL_RESPONSE_STATUSES:
verbose_proxy_logger.info(
f"Response {unified_object_id} is complete. Cost automatically tracked by aget_responses."
)
completed_jobs.append(job)
elif response.status in ["failed", "cancelled"]:
verbose_proxy_logger.info(
f"Response {unified_object_id} has status {response.status}, marking as complete"
f"Response {unified_object_id} has terminal status {response.status}, marking as complete"
)
completed_jobs.append(job)

View file

@ -4,9 +4,11 @@
import base64
import json
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, cast
from typing import TYPE_CHECKING, Any, Dict, Final, List, Literal, Optional, Union, cast
from uuid import NAMESPACE_URL, uuid5
from fastapi import HTTPException
from pydantic import ValidationError
import litellm
from litellm import Router, verbose_logger
@ -33,8 +35,8 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
get_batch_id_from_unified_batch_id,
get_content_type_from_file_object,
get_model_id_from_unified_batch_id,
get_models_from_unified_file_id,
normalize_mime_type_for_provider,
resolve_managed_output_file_model_name,
)
from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccessIssue]
AllMessageValues,
@ -73,6 +75,26 @@ else:
PrismaClient = Any
def _parse_managed_file_object(
raw_file_object: object, unified_file_id: str
) -> Optional[OpenAIFileObject]:
if raw_file_object is None:
return None
try:
return OpenAIFileObject.model_validate(raw_file_object)
except ValidationError as e:
verbose_logger.warning(
f"Failed to parse managed file object {unified_file_id}: "
f"{e.errors(include_input=False, include_url=False, include_context=False)}"
)
return None
except Exception as e:
verbose_logger.warning(
f"Failed to parse managed file object {unified_file_id}: {type(e).__name__}"
)
return None
class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
# Class variables or attributes
def __init__(
@ -215,7 +237,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
)
if result:
return LiteLLM_ManagedFileTable(**result)
return LiteLLM_ManagedFileTable.model_validate(result)
## CHECK DB
db_object = await self.prisma_client.db.litellm_managedfiletable.find_first(
@ -223,7 +245,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
)
if db_object:
return LiteLLM_ManagedFileTable(**db_object.model_dump())
return LiteLLM_ManagedFileTable.model_validate(db_object.model_dump())
return None
async def delete_unified_file_id(
@ -349,7 +371,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
if isinstance(batch.file_object, str)
else batch.file_object
)
batch_obj = LiteLLMBatch(**batch_data)
batch_obj = LiteLLMBatch.model_validate(batch_data)
batch_obj.id = batch.unified_object_id
batch_objects.append(batch_obj)
@ -382,7 +404,16 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
"flat_model_file_ids": {"hasSome": model_object_ids},
}
)
return [OpenAIFileObject(**file_object.file_object) for file_object in file_ids]
return [
parsed_file_object.model_copy(update={"id": row.unified_file_id})
for row in file_ids
if (
parsed_file_object := _parse_managed_file_object(
row.file_object, row.unified_file_id
)
)
is not None
]
async def check_managed_file_id_access(
self, data: Dict, user_api_key_dict: UserAPIKeyAuth
@ -1055,10 +1086,13 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
def get_unified_output_file_id(
self, output_file_id: str, model_id: str, model_name: Optional[str]
) -> str:
deterministic_uuid: Final = uuid5(
uuid5(NAMESPACE_URL, model_id), output_file_id
)
unified_output_file_id = (
SpecialEnums.LITELLM_MANAGED_FILE_COMPLETE_STR.value.format(
"application/json",
str(uuid.uuid4()),
str(deterministic_uuid),
model_name or "",
output_file_id,
model_id,
@ -1094,21 +1128,13 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
) # managed batch id
model_id = cast(Optional[str], response._hidden_params.get("model_id"))
model_name = cast(Optional[str], response._hidden_params.get("model_name"))
resolved_model_name = model_name
# Some providers (e.g. Vertex batch retrieve) do not set model_name on
# the response. In that case, recover target_model_names from the input
# managed file metadata so unified output IDs preserve routing metadata.
if not resolved_model_name and isinstance(unified_file_id, str):
decoded_unified_file_id = (
_is_base64_encoded_unified_file_id(unified_file_id)
or unified_file_id
)
target_model_names = get_models_from_unified_file_id(
decoded_unified_file_id
)
if target_model_names:
resolved_model_name = ",".join(target_model_names)
resolved_model_name = resolve_managed_output_file_model_name(
unified_input_file_id=unified_file_id
if isinstance(unified_file_id, str)
else response.input_file_id,
fallback_model_name=model_name,
)
original_response_id = response.id
if (unified_batch_id or unified_file_id) and model_id:

View file

@ -831,7 +831,7 @@ async def project_info(
)
# Check if user has access to this project (admin or team member)
is_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
is_admin = user_api_key_has_admin_view(user_api_key_dict)
is_team_member = False
if project.team_id and user_api_key_dict.user_id:
@ -886,7 +886,7 @@ async def list_projects(
)
# If proxy admin, get all projects
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
if user_api_key_has_admin_view(user_api_key_dict):
projects: Sequence[
prisma_models.LiteLLM_ProjectTable
] = await prisma_client.db.litellm_projecttable.find_many(

View file

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-enterprise"
version = "0.1.52"
version = "0.1.54"
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.52"
version = "0.1.54"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-enterprise==",

View file

@ -61,9 +61,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--extra bedrock-realtime \
--python python3
RUN mkdir -p /home/nonroot && \
HOME=/home/nonroot prisma generate --schema=./schema.prisma && \
chown -R nonroot:nonroot /home/nonroot/.cache
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
npm_config_cache=/root/.npm \
prisma generate --schema=./schema.prisma
RUN sed -i 's/\r$//' docker/component_entrypoint.sh && chmod +x docker/component_entrypoint.sh
# ---------- Runtime ----------
FROM $LITELLM_RUNTIME_IMAGE AS runtime
@ -83,17 +85,20 @@ ENV HOME=/home/nonroot \
PATH="/app/.venv/bin:${PATH}" \
PYTHONPATH="/app" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1 \
PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries
COPY --from=builder --chown=nonroot:nonroot /app /app
COPY --from=builder --chown=nonroot:nonroot /home/nonroot/.cache /home/nonroot/.cache
COPY --from=builder /opt/prisma /opt/prisma
RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \
find /app/.venv -type d -path "*/tornado/test" -delete
find /app/.venv -type d -path "*/tornado/test" -delete && \
chmod -R a+rX /opt/prisma && \
python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths"
USER nonroot
EXPOSE 4000/tcp
ENTRYPOINT ["sh", "-c", "exec uvicorn gateway.main:app --workers \"${NUM_WORKERS:-1}\" \"$@\"", "--"]
ENTRYPOINT ["sh", "-c", "exec /app/docker/component_entrypoint.sh uvicorn gateway.main:app --workers \"${NUM_WORKERS:-1}\" \"$@\"", "--"]
CMD ["--host", "0.0.0.0", "--port", "4000"]

View file

@ -39,7 +39,7 @@ If `db.useStackgresOperator` is used (not yet implemented):
| `livenessProbe.*` | Liveness probe settings for the LiteLLM container (`path`, `periodSeconds`, `timeoutSeconds`, thresholds, and initial delay). | See `values.yaml` |
| `readinessProbe.*` | Readiness probe settings for the LiteLLM container (`path`, `periodSeconds`, `timeoutSeconds`, thresholds, and initial delay). | See `values.yaml` |
| `startupProbe.*` | Startup probe settings for the LiteLLM container (`path`, `periodSeconds`, `timeoutSeconds`, thresholds, and initial delay). | See `values.yaml` |
| `resources.*` | CPU/memory requests and limits for the LiteLLM container. | `{}` |
| `resources.*` | CPU/memory requests and limits for the LiteLLM container. Unset by default; production deployments should set 1 CPU and 4Gi of memory per worker. | `{}` |
| `service.loadBalancerClass` | Optional LoadBalancer implementation class (only used when `service.type` is `LoadBalancer`) | `""` |
| `ingress.labels` | Additional labels for the Ingress resource | `{}` |
| `ingress.*` | See [values.yaml](./values.yaml) for example settings | N/A |

View file

@ -35,6 +35,8 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "litellm.migrationServiceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.migrationJob.extraInitContainers }}
initContainers:
{{- tpl (toYaml .) $ | nindent 8 }}

View file

@ -254,3 +254,39 @@ tests:
content:
name: sidecar-tpl
image: "ghcr.io/berriai/litellm-database:test"
- it: should render the pod-level securityContext from podSecurityContext
template: migrations-job.yaml
set:
migrationJob:
enabled: true
podSecurityContext:
fsGroup: 10000
runAsUser: 10000
runAsNonRoot: true
asserts:
- equal:
path: spec.template.spec.securityContext
value:
fsGroup: 10000
runAsUser: 10000
runAsNonRoot: true
- it: should keep the pod-level and container-level securityContext separate
template: migrations-job.yaml
set:
migrationJob:
enabled: true
podSecurityContext:
fsGroup: 10000
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
asserts:
- equal:
path: spec.template.spec.securityContext
value:
fsGroup: 10000
- equal:
path: spec.template.spec.containers[0].securityContext
value:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true

View file

@ -181,16 +181,19 @@ proxy_config:
resources:
{}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# Unset by default so the chart installs on small clusters such as Minikube, and so an
# upgrade never leaves a running pod Pending. Production deployments should set these.
# A proxy at DB-connected steady state needs about 1 CPU and 4Gi of memory per worker;
# sizing below that gets the pod OOMKilled once traffic and DB connections ramp up.
# Scale both figures with --num_workers, then uncomment the lines below and remove the
# curly braces after 'resources:'. See "Recommended Machine Specifications" in
# https://docs.litellm.ai/docs/proxy/prod.
# requests:
# cpu: 100m
# memory: 128Mi
# cpu: "1"
# memory: 4Gi
# limits:
# cpu: "1"
# memory: 4Gi
autoscaling:
enabled: false
@ -432,9 +435,9 @@ migrationJob:
annotations: {}
ttlSecondsAfterFinished: 120
resources: {}
# requests:
# cpu: 100m
# memory: 100Mi
# Unset by default. This job runs the database migration and exits, so it does not
# need the steady-state headroom the proxy does; size it from your own migration
# runs rather than from the proxy figures above.
extraContainers: []
extraInitContainers: []

View file

@ -138,6 +138,59 @@ is false the chart uses the provided name, or the namespace `default` SA.
{{- end -}}
{{- end -}}
{{/*
ServiceAccount name for the migrations Job.
The Job is a pre-install / pre-upgrade hook, so it is created before the
chart's ordinary resources. A ServiceAccount the chart creates is one of
those ordinary resources, which makes borrowing the backend name a cycle:
the hook pod is rejected because the account does not exist yet. So when
`serviceAccounts.backend.create` is true the Job falls back to the namespace
`default` account unless the operator names one that already exists. With
`create` false the backend name is either an operator-supplied existing
account or `default`, both of which are safe for the hook, so the Job keeps
sharing it.
`migrationJob.serviceAccountName` always wins when set, which is how a Job
that needs credentials of its own (IRSA / Workload Identity for IAM database
auth) gets them.
*/}}
{{- define "litellm.migrations.serviceAccountName" -}}
{{- if .Values.migrationJob.serviceAccountName -}}
{{ .Values.migrationJob.serviceAccountName }}
{{- else if .Values.serviceAccounts.backend.create -}}
default
{{- else -}}
{{ include "litellm.backend.serviceAccountName" . }}
{{- end -}}
{{- end -}}
{{/*
Extra pod labels for a component's Deployment, validated against its selector.
Invoke with a dict:
(dict "podLabels" .Values.gateway.podLabels "componentName" "gateway")
The three selector keys are also emitted on the pod template, so a podLabels
entry reusing one renders a duplicate YAML key whose later value wins. That
leaves the pod template no longer matching the (immutable) selector and the
apiserver rejects the Deployment. Fail at template time naming the key
instead, so the operator gets the reason here rather than an opaque
`selector does not match template labels` from the apiserver.
The migrations Job takes podLabels unvalidated: a Job's selector is generated
by the controller rather than declared, so nothing there can collide.
*/}}
{{- define "litellm.podLabels" -}}
{{- $componentName := .componentName -}}
{{- range $key, $value := .podLabels }}
{{- if has $key (list "app.kubernetes.io/name" "app.kubernetes.io/instance" "app.kubernetes.io/component") }}
{{- fail (printf "%s.podLabels cannot set %s: it is part of the Deployment's immutable selector" $componentName $key) }}
{{- end }}
{{- end }}
{{- toYaml .podLabels }}
{{- end -}}
{{/*
Master-key + database + redis env block — shared by gateway, backend, and the
migrations Job.

View file

@ -23,9 +23,16 @@ spec:
{{- end }}
labels:
{{- include "litellm.backend.selectorLabels" . | nindent 8 }}
{{- with .Values.backend.podLabels }}
{{- include "litellm.podLabels" (dict "podLabels" . "componentName" "backend") | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "litellm.backend.serviceAccountName" . }}
automountServiceAccountToken: {{ .Values.serviceAccounts.backend.automount }}
{{- with .Values.backend.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
@ -34,6 +41,10 @@ spec:
- name: backend
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
{{- with .Values.backend.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: 4001
@ -70,8 +81,15 @@ spec:
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.lifecycle }}
lifecycle:
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}
{{- with .Values.backend.extraContainers }}
{{- tpl (toYaml .) $ | nindent 8 }}
{{- end }}
{{- if or .Values.gateway.config.create .Values.backend.volumes .Values.billingMetrics.enabled }}
volumes:
{{- if .Values.gateway.config.create }}
@ -102,4 +120,8 @@ spec:
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $gracePeriod := .Values.backend.terminationGracePeriodSeconds }}
{{- if not (or (kindIs "invalid" $gracePeriod) (eq (printf "%v" $gracePeriod) "")) }}
terminationGracePeriodSeconds: {{ $gracePeriod }}
{{- end }}
{{- end }}

View file

@ -21,9 +21,16 @@ spec:
{{- end }}
labels:
{{- include "litellm.gateway.selectorLabels" . | nindent 8 }}
{{- with .Values.gateway.podLabels }}
{{- include "litellm.podLabels" (dict "podLabels" . "componentName" "gateway") | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "litellm.gateway.serviceAccountName" . }}
automountServiceAccountToken: {{ .Values.serviceAccounts.gateway.automount }}
{{- with .Values.gateway.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
@ -32,6 +39,10 @@ spec:
- name: gateway
image: "{{ .Values.gateway.image.repository }}:{{ .Values.gateway.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.gateway.image.pullPolicy }}
{{- with .Values.gateway.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: 4000
@ -72,8 +83,15 @@ spec:
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.gateway.lifecycle }}
lifecycle:
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.gateway.resources | nindent 12 }}
{{- with .Values.gateway.extraContainers }}
{{- tpl (toYaml .) $ | nindent 8 }}
{{- end }}
{{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.billingMetrics.enabled }}
volumes:
{{- if .Values.gateway.config.create }}
@ -104,4 +122,8 @@ spec:
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $gracePeriod := .Values.gateway.terminationGracePeriodSeconds }}
{{- if not (or (kindIs "invalid" $gracePeriod) (eq (printf "%v" $gracePeriod) "")) }}
terminationGracePeriodSeconds: {{ $gracePeriod }}
{{- end }}
{{- end }}

View file

@ -23,12 +23,21 @@ spec:
ttlSecondsAfterFinished: {{ .Values.migrationJob.ttlSecondsAfterFinished }}
template:
metadata:
{{- /* The Job's selector is generated by the controller rather than
declared, so podLabels may override a chart label here. Merge
instead of appending so an override replaces the key rather than
rendering it twice. */}}
{{- $chartLabels := merge (dict "app.kubernetes.io/component" "migrations") (fromYaml (include "litellm.commonLabels" .)) }}
labels:
{{- include "litellm.commonLabels" . | nindent 8 }}
app.kubernetes.io/component: migrations
{{- toYaml (merge (deepCopy .Values.migrationJob.podLabels) $chartLabels) | nindent 8 }}
spec:
restartPolicy: Never
serviceAccountName: {{ include "litellm.backend.serviceAccountName" . }}
serviceAccountName: {{ include "litellm.migrations.serviceAccountName" . }}
automountServiceAccountToken: {{ .Values.migrationJob.automountServiceAccountToken }}
{{- with .Values.migrationJob.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
@ -37,10 +46,22 @@ spec:
- name: prisma-migrations
image: "{{ .Values.migrationJob.image.repository }}:{{ .Values.migrationJob.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.migrationJob.image.pullPolicy }}
{{- with .Values.migrationJob.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- include "litellm.serverEnv" (dict "root" $ "component" .Values.migrationJob) | nindent 12 }}
{{- with .Values.migrationJob.volumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.migrationJob.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.migrationJob.volumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -18,9 +18,16 @@ spec:
{{- end }}
labels:
{{- include "litellm.ui.selectorLabels" . | nindent 8 }}
{{- with .Values.ui.podLabels }}
{{- include "litellm.podLabels" (dict "podLabels" . "componentName" "ui") | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "litellm.ui.serviceAccountName" . }}
automountServiceAccountToken: {{ .Values.serviceAccounts.ui.automount }}
{{- with .Values.ui.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
@ -29,6 +36,10 @@ spec:
- name: ui
image: "{{ .Values.ui.image.repository }}:{{ .Values.ui.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.ui.image.pullPolicy }}
{{- with .Values.ui.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: 3000
@ -58,8 +69,15 @@ spec:
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.ui.lifecycle }}
lifecycle:
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.ui.resources | nindent 12 }}
{{- with .Values.ui.extraContainers }}
{{- tpl (toYaml .) $ | nindent 8 }}
{{- end }}
{{- with .Values.ui.volumes }}
volumes:
{{- toYaml . | nindent 8 }}
@ -80,4 +98,8 @@ spec:
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- $gracePeriod := .Values.ui.terminationGracePeriodSeconds }}
{{- if not (or (kindIs "invalid" $gracePeriod) (eq (printf "%v" $gracePeriod) "")) }}
terminationGracePeriodSeconds: {{ $gracePeriod }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,169 @@
suite: test migrations Job ServiceAccount resolution and pod hardening
templates:
- migrations-job.yaml
values:
- ./values/required.yaml
tests:
- it: borrows the namespace default account when no ServiceAccount is configured
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: default
- it: falls back to the namespace default account when the chart creates the backend ServiceAccount
set:
serviceAccounts.backend.create: true
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: default
- notEqual:
path: spec.template.spec.serviceAccountName
value: RELEASE-NAME-litellm-backend
- it: keeps sharing an existing backend ServiceAccount the chart does not create
set:
serviceAccounts.backend.create: false
serviceAccounts.backend.name: existing-backend-sa
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: existing-backend-sa
- it: prefers an explicit migration ServiceAccount over the created backend one
set:
serviceAccounts.backend.create: true
migrationJob.serviceAccountName: migrations-sa
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: migrations-sa
- it: prefers an explicit migration ServiceAccount over an existing backend one
set:
serviceAccounts.backend.create: false
serviceAccounts.backend.name: existing-backend-sa
migrationJob.serviceAccountName: migrations-sa
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: migrations-sa
- it: mounts no ServiceAccount token by default
asserts:
- equal:
path: spec.template.spec.automountServiceAccountToken
value: false
- it: mounts a ServiceAccount token when the operator asks for one
set:
migrationJob.automountServiceAccountToken: true
asserts:
- equal:
path: spec.template.spec.automountServiceAccountToken
value: true
- it: keeps the token off the Job when the backend disables automounting
set:
serviceAccounts.backend.create: true
serviceAccounts.backend.automount: false
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: default
- equal:
path: spec.template.spec.automountServiceAccountToken
value: false
- it: renders no hardening fields by default
asserts:
- isNull:
path: spec.template.spec.securityContext
- isNull:
path: spec.template.spec.containers[0].securityContext
- isNull:
path: spec.template.spec.volumes
- isNull:
path: spec.template.spec.containers[0].volumeMounts
- equal:
path: spec.template.metadata.labels
value:
app.kubernetes.io/name: litellm
app.kubernetes.io/instance: RELEASE-NAME
app.kubernetes.io/managed-by: Helm
helm.sh/chart: litellm-0.1.0
app.kubernetes.io/component: migrations
- it: renders pod-level and container-level securityContext in their own scopes
set:
migrationJob.podSecurityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
migrationJob.securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
asserts:
- equal:
path: spec.template.spec.securityContext
value:
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
- equal:
path: spec.template.spec.containers[0].securityContext
value:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
- it: renders volumes on the pod and volumeMounts on the migration container
set:
migrationJob.volumes:
- name: tmp
emptyDir:
sizeLimit: 64Mi
migrationJob.volumeMounts:
- name: tmp
mountPath: /tmp
asserts:
- equal:
path: spec.template.spec.volumes
value:
- name: tmp
emptyDir:
sizeLimit: 64Mi
- equal:
path: spec.template.spec.containers[0].volumeMounts
value:
- name: tmp
mountPath: /tmp
- it: merges podLabels with the chart labels on the Job pod
set:
migrationJob.podLabels:
egress-policy: restricted
asserts:
- equal:
path: spec.template.metadata.labels['egress-policy']
value: restricted
- equal:
path: spec.template.metadata.labels['app.kubernetes.io/component']
value: migrations
- it: accepts a podLabel that reuses a chart label, since the Job selector is controller-generated
set:
migrationJob.podLabels:
app.kubernetes.io/component: batch-migrations
asserts:
- notFailedTemplate: {}
- equal:
path: spec.template.metadata.labels['app.kubernetes.io/component']
value: batch-migrations

View file

@ -0,0 +1,298 @@
suite: test pod hardening knobs on the component deployments
templates:
- gateway/deployment.yaml
- gateway/configmap.yaml
- backend/deployment.yaml
- ui/deployment.yaml
values:
- ./values/required.yaml
tests:
- it: gateway renders no hardening fields by default
template: gateway/deployment.yaml
asserts:
- isNull:
path: spec.template.spec.securityContext
- isNull:
path: spec.template.spec.containers[0].securityContext
- isNull:
path: spec.template.spec.containers[0].lifecycle
- isNull:
path: spec.template.spec.terminationGracePeriodSeconds
- lengthEqual:
path: spec.template.spec.containers
count: 1
- equal:
path: spec.template.metadata.labels
value:
app.kubernetes.io/name: litellm
app.kubernetes.io/instance: RELEASE-NAME
app.kubernetes.io/component: gateway
- it: gateway renders pod-level and container-level securityContext in their own scopes
template: gateway/deployment.yaml
set:
gateway.podSecurityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
gateway.securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
asserts:
- equal:
path: spec.template.spec.securityContext
value:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
- equal:
path: spec.template.spec.containers[0].securityContext
value:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
- it: gateway merges podLabels with the selector labels
template: gateway/deployment.yaml
set:
gateway.podLabels:
egress-policy: restricted
team: platform
asserts:
- equal:
path: spec.template.metadata.labels
value:
app.kubernetes.io/name: litellm
app.kubernetes.io/instance: RELEASE-NAME
app.kubernetes.io/component: gateway
egress-policy: restricted
team: platform
- equal:
path: spec.selector.matchLabels
value:
app.kubernetes.io/name: litellm
app.kubernetes.io/instance: RELEASE-NAME
app.kubernetes.io/component: gateway
- it: gateway rejects a podLabel that collides with the selector
template: gateway/deployment.yaml
set:
gateway.podLabels:
app.kubernetes.io/component: not-gateway
asserts:
- failedTemplate:
errorMessage: "gateway.podLabels cannot set app.kubernetes.io/component: it is part of the Deployment's immutable selector"
- it: backend rejects a podLabel that collides with the selector
template: backend/deployment.yaml
set:
backend.podLabels:
app.kubernetes.io/name: not-litellm
asserts:
- failedTemplate:
errorMessage: "backend.podLabels cannot set app.kubernetes.io/name: it is part of the Deployment's immutable selector"
- it: ui rejects a podLabel that collides with the selector
template: ui/deployment.yaml
set:
ui.podLabels:
app.kubernetes.io/instance: not-the-release
asserts:
- failedTemplate:
errorMessage: "ui.podLabels cannot set app.kubernetes.io/instance: it is part of the Deployment's immutable selector"
- it: gateway renders lifecycle hooks on the container
template: gateway/deployment.yaml
set:
gateway.lifecycle:
preStop:
httpGet:
path: /health/drain
port: 4000
asserts:
- equal:
path: spec.template.spec.containers[0].lifecycle
value:
preStop:
httpGet:
path: /health/drain
port: 4000
- it: gateway renders terminationGracePeriodSeconds on the pod spec
template: gateway/deployment.yaml
set:
gateway.terminationGracePeriodSeconds: 90
asserts:
- equal:
path: spec.template.spec.terminationGracePeriodSeconds
value: 90
- it: gateway honors an explicit terminationGracePeriodSeconds of zero
template: gateway/deployment.yaml
set:
gateway.terminationGracePeriodSeconds: 0
asserts:
- equal:
path: spec.template.spec.terminationGracePeriodSeconds
value: 0
- it: gateway appends extraContainers after the gateway container
template: gateway/deployment.yaml
set:
gateway.extraContainers:
- name: auth-sidecar
image: registry.example.com/auth-proxy:1.2.3
args:
- --upstream
- http://127.0.0.1:4000
asserts:
- lengthEqual:
path: spec.template.spec.containers
count: 2
- equal:
path: spec.template.spec.containers[0].name
value: gateway
- equal:
path: spec.template.spec.containers[1]
value:
name: auth-sidecar
image: registry.example.com/auth-proxy:1.2.3
args:
- --upstream
- http://127.0.0.1:4000
- it: gateway templates chart context inside extraContainers
template: gateway/deployment.yaml
set:
gateway.extraContainers:
- name: auth-sidecar
image: registry.example.com/auth-proxy:1.2.3
env:
- name: RELEASE
value: "{{ .Release.Name }}"
asserts:
- equal:
path: spec.template.spec.containers[1].env[0].value
value: RELEASE-NAME
- it: backend renders every hardening knob in the right scope
template: backend/deployment.yaml
set:
backend.podLabels:
egress-policy: restricted
backend.podSecurityContext:
runAsNonRoot: true
backend.securityContext:
readOnlyRootFilesystem: true
backend.lifecycle:
preStop:
exec:
command:
- sleep
- "5"
backend.terminationGracePeriodSeconds: 60
backend.extraContainers:
- name: auth-sidecar
image: registry.example.com/auth-proxy:1.2.3
asserts:
- equal:
path: spec.template.metadata.labels['egress-policy']
value: restricted
- equal:
path: spec.template.spec.securityContext
value:
runAsNonRoot: true
- equal:
path: spec.template.spec.containers[0].securityContext
value:
readOnlyRootFilesystem: true
- equal:
path: spec.template.spec.containers[0].lifecycle
value:
preStop:
exec:
command:
- sleep
- "5"
- equal:
path: spec.template.spec.terminationGracePeriodSeconds
value: 60
- equal:
path: spec.template.spec.containers[1].name
value: auth-sidecar
- it: ui renders every hardening knob in the right scope
template: ui/deployment.yaml
set:
ui.podLabels:
egress-policy: restricted
ui.podSecurityContext:
runAsNonRoot: true
fsGroup: 101
ui.securityContext:
readOnlyRootFilesystem: true
ui.lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- nginx -s quit
ui.terminationGracePeriodSeconds: 30
ui.extraContainers:
- name: auth-sidecar
image: registry.example.com/auth-proxy:1.2.3
asserts:
- equal:
path: spec.template.metadata.labels['egress-policy']
value: restricted
- equal:
path: spec.template.spec.securityContext
value:
runAsNonRoot: true
fsGroup: 101
- equal:
path: spec.template.spec.containers[0].securityContext
value:
readOnlyRootFilesystem: true
- equal:
path: spec.template.spec.containers[0].lifecycle
value:
preStop:
exec:
command:
- /bin/sh
- -c
- nginx -s quit
- equal:
path: spec.template.spec.terminationGracePeriodSeconds
value: 30
- equal:
path: spec.template.spec.containers[1].name
value: auth-sidecar
- it: backend and ui render no hardening fields by default
templates:
- backend/deployment.yaml
- ui/deployment.yaml
asserts:
- isNull:
path: spec.template.spec.securityContext
- isNull:
path: spec.template.spec.containers[0].securityContext
- isNull:
path: spec.template.spec.containers[0].lifecycle
- isNull:
path: spec.template.spec.terminationGracePeriodSeconds
- lengthEqual:
path: spec.template.spec.containers
count: 1

View file

@ -0,0 +1,106 @@
suite: test liveness and readiness probe timeouts
templates:
- gateway/deployment.yaml
- gateway/configmap.yaml
- backend/deployment.yaml
values:
- ./values/required.yaml
tests:
- it: gateway probes set an explicit timeout that outlasts a saturated event loop
template: gateway/deployment.yaml
asserts:
- equal:
path: spec.template.spec.containers[0].livenessProbe
value:
httpGet:
path: /health/liveliness
port: http
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 10
failureThreshold: 6
- equal:
path: spec.template.spec.containers[0].readinessProbe
value:
httpGet:
path: /health/readiness
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 10
- it: backend probes set an explicit timeout that outlasts a saturated event loop
template: backend/deployment.yaml
asserts:
- equal:
path: spec.template.spec.containers[0].livenessProbe
value:
httpGet:
path: /health/liveliness
port: http
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 10
failureThreshold: 6
- equal:
path: spec.template.spec.containers[0].readinessProbe
value:
httpGet:
path: /health/readiness
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 10
- it: no single-event-loop component is left on the kubernetes default 1s probe timeout
templates:
- gateway/deployment.yaml
- backend/deployment.yaml
asserts:
- isNotNullOrEmpty:
path: spec.template.spec.containers[0].livenessProbe.timeoutSeconds
- isNotNullOrEmpty:
path: spec.template.spec.containers[0].readinessProbe.timeoutSeconds
- equal:
path: spec.template.spec.containers[0].livenessProbe.timeoutSeconds
value: 10
- equal:
path: spec.template.spec.containers[0].readinessProbe.timeoutSeconds
value: 10
- it: gateway liveness tolerates a longer outage than readiness before acting
template: gateway/deployment.yaml
asserts:
- equal:
path: spec.template.spec.containers[0].livenessProbe.failureThreshold
value: 6
- notExists:
path: spec.template.spec.containers[0].readinessProbe.failureThreshold
- it: probe timeouts and thresholds stay overridable per component
template: gateway/deployment.yaml
set:
gateway.readinessProbe.timeoutSeconds: 3
gateway.readinessProbe.periodSeconds: 20
gateway.livenessProbe.timeoutSeconds: 4
gateway.livenessProbe.failureThreshold: 3
asserts:
- equal:
path: spec.template.spec.containers[0].readinessProbe
value:
httpGet:
path: /health/readiness
port: http
initialDelaySeconds: 5
periodSeconds: 20
timeoutSeconds: 3
- equal:
path: spec.template.spec.containers[0].livenessProbe
value:
httpGet:
path: /health/liveliness
port: http
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 4
failureThreshold: 3

View file

@ -57,6 +57,42 @@ migrationJob:
backoffLimit: 4
ttlSecondsAfterFinished: 120
resources: {}
# ServiceAccount for the Job pod only.
#
# The Job is a pre-install / pre-upgrade hook, so it runs before the chart's
# ordinary resources exist. With `serviceAccounts.backend.create: true` the
# backend ServiceAccount is one of those ordinary resources, so a Job that
# borrowed its name would reference an account that does not exist yet and
# the first install would fail with a forbidden pod creation. The name set
# here always wins; when it is empty the Job falls back to `default` if the
# chart creates the backend ServiceAccount, and to the backend
# ServiceAccount name otherwise (that name is either an existing account you
# supplied or `default`).
#
# Point this at a pre-existing ServiceAccount when the Job needs credentials
# of its own, e.g. the IRSA / Workload Identity annotations that
# `database.writer.useIAMAuth` relies on. That is also the upgrade path to
# watch: a release already running with `serviceAccounts.backend.create:
# true` used to hand the Job the created backend account on every upgrade,
# and now hands it `default` unless you name an account here.
serviceAccountName: ""
# The Job runs `prisma migrate deploy` against Postgres and never calls the
# K8s API, so it defaults to no projected ServiceAccount token, the same
# reasoning the ui SA above uses. Flip to true if your Job genuinely needs
# one; IAM database auth does not, since EKS Pod Identity injects its own
# projected token volume and GKE Workload Identity goes through the
# metadata server, neither of which is the default token mount.
automountServiceAccountToken: false
# Standard k8s pod-level and container-level securityContext for the Job
# pod. Same shape as gateway.podSecurityContext / gateway.securityContext.
podSecurityContext: {}
securityContext: {}
# Extra pod labels on the Job pod, merged into the chart's common labels.
podLabels: {}
# Additional volumes on the Job pod and volumeMounts on its container, e.g.
# the writable scratch space a read-only root filesystem needs.
volumes: []
volumeMounts: []
image:
repository: ghcr.io/berriai/litellm-migrations
tag: "" # defaults to .Chart.AppVersion
@ -180,10 +216,13 @@ gateway:
httpGet: { path: /health/liveliness, port: http }
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 10
failureThreshold: 6
readinessProbe:
httpGet: { path: /health/readiness, port: http }
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 10
hpa:
enabled: true
minReplicas: 1
@ -200,6 +239,37 @@ gateway:
minAvailable: ""
maxUnavailable: ""
podAnnotations: {}
# Extra pod labels, merged into the chart's selector labels. Do not
# re-declare `app.kubernetes.io/name` / `instance` / `component` here: they
# form the Deployment's immutable selector.
podLabels: {}
# Pod-level securityContext, applied to every container in the pod
# (runAsNonRoot, runAsUser, fsGroup, seccompProfile, ...). Empty by default
# so the cluster's own defaults keep applying to existing installs; clusters
# enforcing a restricted Pod Security Standard usually want at least
# `runAsNonRoot: true` and `seccompProfile.type: RuntimeDefault`.
podSecurityContext: {}
# Container-level securityContext for the gateway container. Empty by
# default for the same reason. Example:
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
# `readOnlyRootFilesystem: true` needs writable scratch space; supply it
# through `volumes` / `volumeMounts` above rather than expecting the chart
# to guess the paths your workload writes to.
securityContext: {}
# Extra sidecar containers appended to the gateway pod, e.g. an auth or
# egress proxy. Rendered through `tpl`, so entries may reference chart
# values and release metadata.
extraContainers: []
# Container lifecycle hooks (postStart / preStop) for the gateway container.
lifecycle: {}
# Grace period the kubelet allows between SIGTERM and SIGKILL. Leave empty
# to inherit the Kubernetes default of 30s. Set it a few seconds above the
# proxy's GRACEFUL_SHUTDOWN_TIMEOUT when you use a draining preStop hook.
terminationGracePeriodSeconds: ""
nodeSelector: {}
tolerations: []
affinity: {}
@ -242,10 +312,13 @@ backend:
httpGet: { path: /health/liveliness, port: http }
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 10
failureThreshold: 6
readinessProbe:
httpGet: { path: /health/readiness, port: http }
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 10
hpa:
enabled: true
minReplicas: 1
@ -257,6 +330,13 @@ backend:
minAvailable: ""
maxUnavailable: ""
podAnnotations: {}
# Same shape as the gateway blocks of the same name.
podLabels: {}
podSecurityContext: {}
securityContext: {}
extraContainers: []
lifecycle: {}
terminationGracePeriodSeconds: ""
nodeSelector: {}
tolerations: []
affinity: {}
@ -310,6 +390,16 @@ ui:
minAvailable: ""
maxUnavailable: ""
podAnnotations: {}
# Same shape as the gateway blocks of the same name. The nginx runtime
# writes its pid, cache, and proxy temp files under the image's root
# filesystem, so `securityContext.readOnlyRootFilesystem: true` here needs
# emptyDir volumes mounted over those paths.
podLabels: {}
podSecurityContext: {}
securityContext: {}
extraContainers: []
lifecycle: {}
terminationGracePeriodSeconds: ""
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "LiteLLM_Config" ADD COLUMN IF NOT EXISTS "last_run_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "reload_revision" BIGINT NOT NULL DEFAULT 0;

View file

@ -0,0 +1,17 @@
-- AlterTable
ALTER TABLE "LiteLLM_DailyUserSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyOrganizationSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyEndUserSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyAgentSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyTeamSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyTagSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;

View file

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "LiteLLM_DailyGatewayRequests" (
"date" TEXT NOT NULL,
"category" TEXT NOT NULL,
"route" TEXT NOT NULL,
"successful_requests" BIGINT NOT NULL DEFAULT 0,
"failed_requests" BIGINT NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LiteLLM_DailyGatewayRequests_pkey" PRIMARY KEY ("date","category","route")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_DailyGatewayRequests_date_idx" ON "LiteLLM_DailyGatewayRequests"("date");

View file

@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS "LiteLLM_AutoRouterSession" (
"api_key" TEXT NOT NULL,
"session_id" TEXT NOT NULL,
"router_name" TEXT NOT NULL,
"router_type" TEXT NOT NULL,
"first_turn_at" TIMESTAMP(3) NOT NULL,
"last_turn_at" TIMESTAMP(3) NOT NULL,
"last_model" TEXT NOT NULL,
"models" JSONB NOT NULL DEFAULT '{}',
"turns" INTEGER NOT NULL DEFAULT 0,
"unordered_turns" INTEGER NOT NULL DEFAULT 0,
"covered_turns" INTEGER NOT NULL DEFAULT 0,
"cache_hits" INTEGER NOT NULL DEFAULT 0,
"same_model_turns" INTEGER NOT NULL DEFAULT 0,
"same_model_hits" INTEGER NOT NULL DEFAULT 0,
"first_visit_turns" INTEGER NOT NULL DEFAULT 0,
"first_visit_hits" INTEGER NOT NULL DEFAULT 0,
"return_turns" INTEGER NOT NULL DEFAULT 0,
"return_hits" INTEGER NOT NULL DEFAULT 0,
"return_expired_misses" INTEGER NOT NULL DEFAULT 0,
"return_within_ttl_misses" INTEGER NOT NULL DEFAULT 0,
"ttl_5m_turns" INTEGER NOT NULL DEFAULT 0,
"ttl_1h_turns" INTEGER NOT NULL DEFAULT 0,
"total_tokens" BIGINT NOT NULL DEFAULT 0,
"spend" DOUBLE PRECISION NOT NULL DEFAULT 0,
"saved_spend" DOUBLE PRECISION NOT NULL DEFAULT 0,
CONSTRAINT "LiteLLM_AutoRouterSession_pkey" PRIMARY KEY ("api_key", "session_id", "router_name")
);
CREATE INDEX IF NOT EXISTS "idx_autorouter_session_last_turn" ON "LiteLLM_AutoRouterSession"("last_turn_at");

View file

@ -0,0 +1,181 @@
"""Prepare the Node toolchain the Prisma CLI needs, separately from migrations.
The Prisma CLI is a Node program. The first invocation inside a fresh
container installs a private Node runtime and npm-installs the CLI itself,
which can take minutes on a cold or slow machine. Sharing one timeout between
that one-time bootstrap and the migration commands makes a slow bootstrap
indistinguishable from a slow migration, so the bootstrap gets killed long
before it can finish.
A killed bootstrap does not correct itself. The installer leaves its cache
directory behind, and Prisma decides whether to install by testing that
directory for existence alone, so every later attempt skips the install and
then fails on a Node binary that was never written. Deleting a cache directory
that exists without a Node binary is what turns a killed bootstrap back into a
recoverable one.
Both budgets are overridable so an operator can widen them without a release:
``LITELLM_PRISMA_BOOTSTRAP_TIMEOUT`` for the toolchain install and
``LITELLM_PRISMA_COMMAND_TIMEOUT`` for every individual Prisma command.
"""
import math
import os
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from litellm_proxy_extras._logging import logger
try:
from prisma import config as prisma_config
except ImportError:
prisma_config = None
PRISMA_COMMAND_TIMEOUT_ENV_VAR = "LITELLM_PRISMA_COMMAND_TIMEOUT"
PRISMA_BOOTSTRAP_TIMEOUT_ENV_VAR = "LITELLM_PRISMA_BOOTSTRAP_TIMEOUT"
NODEENV_CACHE_DIR_ENV_VAR = "PRISMA_NODEENV_CACHE_DIR"
DEFAULT_PRISMA_COMMAND_TIMEOUT = 60.0
DEFAULT_PRISMA_BOOTSTRAP_TIMEOUT = 600.0
BOOTSTRAP_ARG = "--version"
@dataclass(frozen=True)
class ToolchainBootstrap:
"""Outcome of preparing the Prisma toolchain."""
healed_incomplete_cache: bool
ready: bool
def _timeout_from_env(env_var: str, default: float) -> float:
raw = os.getenv(env_var)
if raw is None:
return default
try:
seconds = float(raw)
except ValueError:
logger.warning(
"%s=%r is not a number, falling back to %ss", env_var, raw, default
)
return default
if not math.isfinite(seconds) or seconds <= 0:
logger.warning(
"%s=%r is not a finite positive number, falling back to %ss",
env_var,
raw,
default,
)
return default
return seconds
def prisma_command_timeout() -> float:
"""Seconds any single Prisma command may run for."""
return _timeout_from_env(
PRISMA_COMMAND_TIMEOUT_ENV_VAR, DEFAULT_PRISMA_COMMAND_TIMEOUT
)
def prisma_bootstrap_timeout() -> float:
"""Seconds the one-time Node toolchain install may run for."""
return _timeout_from_env(
PRISMA_BOOTSTRAP_TIMEOUT_ENV_VAR, DEFAULT_PRISMA_BOOTSTRAP_TIMEOUT
)
def nodeenv_cache_dir() -> Optional[Path]:
"""Where Prisma installs its private Node runtime, or None if unknowable."""
override = os.getenv(NODEENV_CACHE_DIR_ENV_VAR)
if override:
return Path(override).absolute()
if prisma_config is not None:
try:
return Path(prisma_config.nodeenv_cache_dir).absolute()
except (OSError, ValueError) as e:
logger.warning("Could not read the Prisma nodeenv cache dir: %s", e)
try:
return Path.home() / ".cache" / "prisma-python" / "nodeenv"
except RuntimeError:
logger.warning(
"No resolvable home directory, cannot locate the Prisma nodeenv cache"
)
return None
def node_binary_path(cache_dir: Path) -> Path:
"""Path the Node binary occupies once the toolchain is fully installed."""
if os.name == "nt":
return cache_dir / "Scripts" / "node.exe"
return cache_dir / "bin" / "node"
def heal_incomplete_nodeenv_cache() -> bool:
"""Delete a nodeenv cache directory left without a Node binary.
Returns True when a half-installed toolchain was removed, so the next
Prisma invocation reinstalls it instead of failing on a missing binary.
"""
cache_dir = nodeenv_cache_dir()
if cache_dir is None:
return False
try:
if not cache_dir.is_dir() or node_binary_path(cache_dir).exists():
return False
except OSError as e:
logger.warning("Could not inspect the Node toolchain at %s: %s", cache_dir, e)
return False
logger.warning(
"Node toolchain at %s has no %s, so a previous install was interrupted. "
"Removing it so it can be reinstalled.",
cache_dir,
node_binary_path(cache_dir).name,
)
try:
shutil.rmtree(cache_dir)
except OSError as e:
logger.warning("Could not remove %s: %s", cache_dir, e)
return False
return True
def ensure_prisma_toolchain(
prisma_command: str, prisma_env: dict[str, str]
) -> ToolchainBootstrap:
"""Install whatever the Prisma CLI needs to run, under its own timeout.
Never raises. A toolchain that cannot be prepared is reported so the
caller can go on and let the real Prisma command produce the real error.
"""
healed = heal_incomplete_nodeenv_cache()
timeout = prisma_bootstrap_timeout()
logger.info("Preparing the Prisma CLI toolchain (timeout %ss)", timeout)
try:
subprocess.run(
[prisma_command, BOOTSTRAP_ARG],
timeout=timeout,
check=True,
capture_output=True,
text=True,
env=prisma_env,
)
except subprocess.TimeoutExpired:
logger.warning(
"Preparing the Prisma CLI toolchain timed out after %ss. Raise %s "
"if this machine needs longer to install it.",
timeout,
PRISMA_BOOTSTRAP_TIMEOUT_ENV_VAR,
)
return ToolchainBootstrap(healed_incomplete_cache=healed, ready=False)
except subprocess.CalledProcessError as e:
logger.warning("Preparing the Prisma CLI toolchain failed: %s", e.stderr)
return ToolchainBootstrap(healed_incomplete_cache=healed, ready=False)
except OSError as e:
logger.warning("Could not run the Prisma CLI: %s", e)
return ToolchainBootstrap(healed_incomplete_cache=healed, ready=False)
logger.info("Prisma CLI toolchain ready")
return ToolchainBootstrap(healed_incomplete_cache=healed, ready=True)

View file

@ -16,6 +16,7 @@ import tempfile
from pathlib import Path
from litellm_proxy_extras._logging import logger
from litellm_proxy_extras.prisma_toolchain import prisma_command_timeout
REPLICA_IDENTITY_FULL_ENV_VAR = "LITELLM_SET_REPLICA_IDENTITY_FULL"
@ -75,7 +76,7 @@ def apply_replica_identity_full(
"--schema",
schema_path,
],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
capture_output=True,
text=True,

View file

@ -601,6 +601,8 @@ model LiteLLM_TagTable {
model LiteLLM_Config {
param_name String @id
param_value Json?
last_run_at DateTime?
reload_revision BigInt @default(0)
}
// View spend, model, api_key per request
@ -748,6 +750,7 @@ model LiteLLM_DailyUserSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -782,6 +785,7 @@ model LiteLLM_DailyOrganizationSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -816,6 +820,7 @@ model LiteLLM_DailyEndUserSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -849,6 +854,7 @@ model LiteLLM_DailyAgentSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -882,6 +888,7 @@ model LiteLLM_DailyTeamSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -917,6 +924,7 @@ model LiteLLM_DailyTagSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -1110,6 +1118,26 @@ model LiteLLM_DailyToolSpend {
@@id([date, tool_name])
}
// Gateway request counts recorded at the ASGI edge by
// BillableRequestMetricsMiddleware. This is the source of truth for SGR
// (successful gateway requests): it counts what the proxy actually answered,
// independent of whether the request reached litellm's logging callbacks.
// The key carries no deployment or caller dimension. Every part of it is
// chosen by the proxy and drawn from a closed set, so the table is bounded by
// (days x categories x routes) rather than by anything a caller can vary.
model LiteLLM_DailyGatewayRequests {
date String
category String
route String
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@id([date, category, route])
@@index([date])
}
// Prompt table for storing prompt configurations
model LiteLLM_PromptTable {
id String @id @default(uuid())
@ -1385,6 +1413,37 @@ model LiteLLM_AdaptiveRouterSession {
@@index([last_activity_at], map: "idx_adaptive_router_session_activity")
}
model LiteLLM_AutoRouterSession {
api_key String
session_id String
router_name String
router_type String
first_turn_at DateTime
last_turn_at DateTime
last_model String
models Json @default("{}")
turns Int @default(0)
unordered_turns Int @default(0)
covered_turns Int @default(0)
cache_hits Int @default(0)
same_model_turns Int @default(0)
same_model_hits Int @default(0)
first_visit_turns Int @default(0)
first_visit_hits Int @default(0)
return_turns Int @default(0)
return_hits Int @default(0)
return_expired_misses Int @default(0)
return_within_ttl_misses Int @default(0)
ttl_5m_turns Int @default(0)
ttl_1h_turns Int @default(0)
total_tokens BigInt @default(0)
spend Float @default(0)
saved_spend Float @default(0)
@@id([api_key, session_id, router_name])
@@index([last_turn_at], map: "idx_autorouter_session_last_turn")
}
// ---------------------------------------------------------------------------
// Workflow Run Tracking
//

View file

@ -14,6 +14,10 @@ from litellm_proxy_extras.replica_identity import (
REPLICA_IDENTITY_FULL_ENV_VAR,
apply_replica_identity_full,
)
from litellm_proxy_extras.prisma_toolchain import (
ensure_prisma_toolchain,
prisma_command_timeout,
)
def str_to_bool(value: Optional[str]) -> bool:
@ -142,7 +146,7 @@ class ProxyExtrasDBManager:
],
stdout=open(migration_file, "w"),
check=True,
timeout=30,
timeout=prisma_command_timeout(),
env=prisma_env,
)
@ -157,7 +161,7 @@ class ProxyExtrasDBManager:
"0_init",
],
check=True,
timeout=30,
timeout=prisma_command_timeout(),
env=prisma_env,
)
@ -193,7 +197,7 @@ class ProxyExtrasDBManager:
"--rolled-back",
migration_name,
],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
capture_output=True,
env=prisma_env,
@ -205,7 +209,7 @@ class ProxyExtrasDBManager:
prisma_env = _get_prisma_env()
subprocess.run(
[_get_prisma_command(), "migrate", "resolve", "--applied", migration_name],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
capture_output=True,
env=prisma_env,
@ -303,7 +307,7 @@ class ProxyExtrasDBManager:
"--script",
],
check=True,
timeout=60,
timeout=prisma_command_timeout(),
stdout=f,
env=_get_prisma_env(),
)
@ -335,7 +339,7 @@ class ProxyExtrasDBManager:
"--schema",
schema_path,
],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
capture_output=True,
text=True,
@ -364,7 +368,7 @@ class ProxyExtrasDBManager:
"--schema",
schema_path,
],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
capture_output=True,
text=True,
@ -393,7 +397,7 @@ class ProxyExtrasDBManager:
"--applied",
migration_name,
],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
capture_output=True,
text=True,
@ -530,7 +534,7 @@ class ProxyExtrasDBManager:
try:
subprocess.run(
[_get_prisma_command(), "db", "push", "--accept-data-loss"],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
env=_get_prisma_env(),
)
@ -555,7 +559,7 @@ class ProxyExtrasDBManager:
try:
result = subprocess.run(
[_get_prisma_command(), "migrate", "deploy"],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
capture_output=True,
text=True,
@ -731,6 +735,9 @@ class ProxyExtrasDBManager:
Returns:
bool: True if setup was successful, False otherwise
"""
ensure_prisma_toolchain(
prisma_command=_get_prisma_command(), prisma_env=_get_prisma_env()
)
migrated = ProxyExtrasDBManager._run_migrations(
use_migrate=use_migrate, use_v2_resolver=use_v2_resolver
)
@ -757,7 +764,7 @@ class ProxyExtrasDBManager:
# Set migrations directory for Prisma
result = subprocess.run(
[_get_prisma_command(), "migrate", "deploy"],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
capture_output=True,
text=True,
@ -840,7 +847,7 @@ class ProxyExtrasDBManager:
"--rolled-back",
failed_migration,
],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
capture_output=True,
text=True,
@ -968,7 +975,7 @@ class ProxyExtrasDBManager:
# Use prisma db push with increased timeout
subprocess.run(
[_get_prisma_command(), "db", "push", "--accept-data-loss"],
timeout=60,
timeout=prisma_command_timeout(),
check=True,
)
return True

View file

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

View file

@ -27,18 +27,19 @@ if os.getenv("LITELLM_MODE", "DEV") == "DEV":
_dotenv.load_dotenv(override=_dev_env_hot_reload_enabled())
from typing import (
Callable,
List,
Optional,
Dict,
Union,
Any,
Literal,
Callable,
Dict,
Final,
get_args,
TYPE_CHECKING,
Tuple,
List,
Literal,
Optional,
overload,
Tuple,
Type,
TYPE_CHECKING,
Union,
)
from litellm.types.integrations.datadog import DatadogInitParams
from litellm.types.integrations.newrelic import NewRelicInitParams
@ -243,6 +244,7 @@ use_chat_completions_url_for_anthropic_messages: bool = bool(
# Or via `litellm_settings.strip_anthropic_total_tokens: true` in
# config.yaml.
strip_anthropic_total_tokens: bool = False
anthropic_sse_ping_interval_seconds: float = 15.0
route_all_chat_openai_to_responses: bool = (
os.getenv("LITELLM_ROUTE_ALL_CHAT_OPENAI_TO_RESPONSES", "false").lower() == "true"
) # When True, routes all OpenAI /chat/completions requests through the Responses API bridge
@ -264,6 +266,7 @@ databricks_key: Optional[str] = None
openai_like_key: Optional[str] = None
azure_key: Optional[str] = None
anthropic_key: Optional[str] = None
autorouter_savings_baseline_model: Optional[str] = None
replicate_key: Optional[str] = None
bytez_key: Optional[str] = None
gdc_key: Optional[str] = None
@ -449,6 +452,8 @@ enable_end_user_cost_tracking_prometheus_only: Optional[bool] = None
custom_prometheus_metadata_labels: List[str] = []
custom_prometheus_tags: List[str] = []
prometheus_metrics_config: Optional[List] = None
prometheus_exclude_metrics: Optional[List[str]] = None
prometheus_exclude_labels: Optional[List[str]] = None
prometheus_emit_stream_label: bool = False
# Opt-in: emit `rate_limit_category` and `rate_limit_type` labels on
# `litellm_proxy_failed_requests_metric`. Off by default to preserve the
@ -678,12 +683,12 @@ def is_bedrock_pricing_only_model(key: str) -> bool:
bool: True if the key matches the Bedrock pattern, False otherwise.
"""
# Regex to match 'bedrock/<region>/<model>'
bedrock_pattern = re.compile(r"^bedrock/[a-zA-Z0-9_-]+/.+$")
bedrock_pattern: Final = re.compile(r"^bedrock/[a-zA-Z0-9_-]+/.+$")
if "month-commitment" in key:
return True
is_match = bedrock_pattern.match(key)
is_match: Final = bedrock_pattern.match(key)
return is_match is not None
@ -700,9 +705,8 @@ def is_openai_finetune_model(key: str) -> bool:
return key.startswith("ft:") and not key.count(":") > 1
def add_known_models(model_cost_map: Optional[Dict] = None):
_map = model_cost_map if model_cost_map is not None else model_cost
for key, value in _map.items():
def _populate_provider_model_sets(model_cost_map: Dict) -> None:
for key, value in model_cost_map.items():
if value.get("litellm_provider") == "openai" and not is_openai_finetune_model(key):
open_ai_chat_completion_models.add(key)
elif value.get("litellm_provider") == "text-completion-openai":
@ -945,7 +949,16 @@ def add_known_models(model_cost_map: Optional[Dict] = None):
bedrock_mantle_models.add(key)
add_known_models()
def add_known_models(model_cost_map: Optional[Dict] = None):
"""Fold `model_cost_map` (defaults to `litellm.model_cost`) into the per-provider model sets,
then refresh `models_by_provider` from those sets so the additions reach wildcard expansion.
The refresh updates the dict in place, so references captured before a reload stay live.
"""
_populate_provider_model_sets(model_cost_map if model_cost_map is not None else model_cost)
models_by_provider.update(_build_models_by_provider())
_populate_provider_model_sets(model_cost)
# known openai compatible endpoints - we'll eventually move this list to the model_prices_and_context_window.json dictionary
# this is maintained for Exception Mapping
@ -1067,112 +1080,116 @@ model_list_set = set(model_list)
# provider_list is lazy-loaded via __getattr__ to avoid importing LlmProviders at import time
models_by_provider: dict = {
"openai": open_ai_chat_completion_models | open_ai_text_completion_models,
"text-completion-openai": open_ai_text_completion_models,
"cohere": cohere_models | cohere_chat_models,
"cohere_chat": cohere_chat_models,
"anthropic": anthropic_models,
"replicate": replicate_models,
"huggingface": huggingface_models,
"together_ai": together_ai_models,
"baseten": baseten_models,
"openrouter": openrouter_models,
"vercel_ai_gateway": vercel_ai_gateway_models,
"datarobot": datarobot_models,
"vertex_ai": vertex_chat_models
| vertex_text_models
| vertex_anthropic_models
| vertex_vision_models
| vertex_language_models
| vertex_deepseek_models
| vertex_minimax_models
| vertex_moonshot_models
| vertex_zai_models,
"ai21": ai21_models,
"bedrock": bedrock_models | bedrock_converse_models,
"petals": petals_models,
"ollama": ollama_models,
"ollama_chat": ollama_models,
"deepinfra": deepinfra_models,
"perplexity": perplexity_models,
"maritalk": maritalk_models,
"watsonx": watsonx_models,
"gemini": gemini_models,
"fireworks_ai": fireworks_ai_models | fireworks_ai_embedding_models,
"aleph_alpha": aleph_alpha_models,
"text-completion-codestral": text_completion_codestral_models,
"text-completion-inception": text_completion_inception_models,
"xai": xai_models,
"zai": zai_models,
"fal_ai": fal_ai_models,
"deepseek": deepseek_models,
"tencent": tencent_models,
"runwayml": runwayml_models,
"mistral": mistral_chat_models,
"azure_ai": azure_ai_models,
"voyage": voyage_models,
"infinity": infinity_models,
"databricks": databricks_models,
"cloudflare": cloudflare_models,
"codestral": codestral_models,
"nlp_cloud": nlp_cloud_models,
"friendliai": friendliai_models,
"palm": palm_models,
"groq": groq_models,
"azure": azure_models | azure_text_models,
"azure_anthropic": azure_anthropic_models,
"azure_text": azure_text_models,
"anyscale": anyscale_models,
"cerebras": cerebras_models,
"galadriel": galadriel_models,
"nvidia_nim": nvidia_nim_models,
"nvidia_riva": nvidia_riva_models,
"soniox": soniox_models,
"sambanova": sambanova_models | sambanova_embedding_models,
"novita": novita_models,
"nebius": nebius_models | nebius_embedding_models,
"aiml": aiml_models,
"assemblyai": assemblyai_models,
"jina_ai": jina_ai_models,
"snowflake": snowflake_models,
"gradient_ai": gradient_ai_models,
"meta_llama": llama_models,
"nscale": nscale_models,
"featherless_ai": featherless_ai_models,
"deepgram": deepgram_models,
"elevenlabs": elevenlabs_models,
"heroku": heroku_models,
"dashscope": dashscope_models,
"modelscope": modelscope_models,
"moonshot": moonshot_models,
"publicai": publicai_models,
"darkbloom": darkbloom_models,
"v0": v0_models,
"morph": morph_models,
"lambda_ai": lambda_ai_models,
"inception": inception_models,
"hyperbolic": hyperbolic_models,
"black_forest_labs": black_forest_labs_models,
"recraft": recraft_models,
"cometapi": cometapi_models,
"oci": oci_models,
"volcengine": volcengine_models,
"wandb": wandb_models,
"ovhcloud": ovhcloud_models | ovhcloud_embedding_models,
"lemonade": lemonade_models,
"clarifai": clarifai_models,
"amazon_nova": amazon_nova_models,
"stability": stability_models,
"github_copilot": github_copilot_models,
"chatgpt": chatgpt_models,
"minimax": minimax_models,
"aws_polly": aws_polly_models,
"gigachat": gigachat_models,
"llamagate": llamagate_models,
"reducto": reducto_models,
"bedrock_mantle": bedrock_mantle_models,
}
def _build_models_by_provider() -> dict:
return {
"openai": open_ai_chat_completion_models | open_ai_text_completion_models,
"text-completion-openai": open_ai_text_completion_models,
"cohere": cohere_models | cohere_chat_models,
"cohere_chat": cohere_chat_models,
"anthropic": anthropic_models,
"replicate": replicate_models,
"huggingface": huggingface_models,
"together_ai": together_ai_models,
"baseten": baseten_models,
"openrouter": openrouter_models,
"vercel_ai_gateway": vercel_ai_gateway_models,
"datarobot": datarobot_models,
"vertex_ai": vertex_chat_models
| vertex_text_models
| vertex_anthropic_models
| vertex_vision_models
| vertex_language_models
| vertex_deepseek_models
| vertex_minimax_models
| vertex_moonshot_models
| vertex_zai_models,
"ai21": ai21_models,
"bedrock": bedrock_models | bedrock_converse_models,
"petals": petals_models,
"ollama": ollama_models,
"ollama_chat": ollama_models,
"deepinfra": deepinfra_models,
"perplexity": perplexity_models,
"maritalk": maritalk_models,
"watsonx": watsonx_models,
"gemini": gemini_models,
"fireworks_ai": fireworks_ai_models | fireworks_ai_embedding_models,
"aleph_alpha": aleph_alpha_models,
"text-completion-codestral": text_completion_codestral_models,
"text-completion-inception": text_completion_inception_models,
"xai": xai_models,
"zai": zai_models,
"fal_ai": fal_ai_models,
"deepseek": deepseek_models,
"tencent": tencent_models,
"runwayml": runwayml_models,
"mistral": mistral_chat_models,
"azure_ai": azure_ai_models,
"voyage": voyage_models,
"infinity": infinity_models,
"databricks": databricks_models,
"cloudflare": cloudflare_models,
"codestral": codestral_models,
"nlp_cloud": nlp_cloud_models,
"friendliai": friendliai_models,
"palm": palm_models,
"groq": groq_models,
"azure": azure_models | azure_text_models,
"azure_anthropic": azure_anthropic_models,
"azure_text": azure_text_models,
"anyscale": anyscale_models,
"cerebras": cerebras_models,
"galadriel": galadriel_models,
"nvidia_nim": nvidia_nim_models,
"nvidia_riva": nvidia_riva_models,
"soniox": soniox_models,
"sambanova": sambanova_models | sambanova_embedding_models,
"novita": novita_models,
"nebius": nebius_models | nebius_embedding_models,
"aiml": aiml_models,
"assemblyai": assemblyai_models,
"jina_ai": jina_ai_models,
"snowflake": snowflake_models,
"gradient_ai": gradient_ai_models,
"meta_llama": llama_models,
"nscale": nscale_models,
"featherless_ai": featherless_ai_models,
"deepgram": deepgram_models,
"elevenlabs": elevenlabs_models,
"heroku": heroku_models,
"dashscope": dashscope_models,
"modelscope": modelscope_models,
"moonshot": moonshot_models,
"publicai": publicai_models,
"darkbloom": darkbloom_models,
"v0": v0_models,
"morph": morph_models,
"lambda_ai": lambda_ai_models,
"inception": inception_models,
"hyperbolic": hyperbolic_models,
"black_forest_labs": black_forest_labs_models,
"recraft": recraft_models,
"cometapi": cometapi_models,
"oci": oci_models,
"volcengine": volcengine_models,
"wandb": wandb_models,
"ovhcloud": ovhcloud_models | ovhcloud_embedding_models,
"lemonade": lemonade_models,
"clarifai": clarifai_models,
"amazon_nova": amazon_nova_models,
"stability": stability_models,
"github_copilot": github_copilot_models,
"chatgpt": chatgpt_models,
"minimax": minimax_models,
"aws_polly": aws_polly_models,
"gigachat": gigachat_models,
"llamagate": llamagate_models,
"reducto": reducto_models,
"bedrock_mantle": bedrock_mantle_models,
}
models_by_provider: dict = _build_models_by_provider()
# mapping for those models which have larger equivalents
longer_context_model_fallback_dict: dict = {
@ -1265,8 +1282,8 @@ from .llms.xai.common_utils import XAIModelInfo
from litellm.types.utils import LlmProviders
## Lazy loading this is not straightforward, will leave it here for now.
from .main import * # type: ignore
from .compression import compress # type: ignore[no-redef]
from .main import *
from .compression import compress
# Skills API
from .skills.main import (
@ -1337,7 +1354,7 @@ from .assistants.main import *
from .batches.main import *
from .images.main import *
from .videos.main import *
from .batch_completion.main import * # type: ignore
from .batch_completion.main import *
from .rerank_api.main import *
from .llms.anthropic.experimental_pass_through.messages.handler import *
from .responses.main import *
@ -2050,7 +2067,7 @@ if TYPE_CHECKING:
supports_reasoning: Callable[..., bool]
acreate: Callable[..., Any]
get_max_tokens: Callable[..., int]
get_model_info: Callable[..., _ModelInfoType] # type: ignore[no-redef]
get_model_info: Callable[..., _ModelInfoType]
register_prompt_template: Callable[..., None]
validate_environment: Callable[..., dict]
check_valid_key: Callable[..., bool]
@ -2137,18 +2154,18 @@ def __getattr__(name: str) -> Any:
# Use cached registry from _lazy_imports instead of importing tuples every time
from ._lazy_imports import _get_lazy_import_registry
registry = _get_lazy_import_registry()
registry: Final = _get_lazy_import_registry()
# Check if name is in registry and call the cached handler function
if name in registry:
handler_func = registry[name]
handler_func: Final = registry[name]
return handler_func(name)
# Lazy load encoding from main.py to avoid heavy tiktoken import
if name == "encoding":
from ._lazy_imports import _get_litellm_globals
from ._lazy_imports import get_litellm_globals
_globals = _get_litellm_globals()
_globals = get_litellm_globals()
# Check if already cached
if "encoding" not in _globals:
from .main import encoding as _encoding
@ -2158,9 +2175,9 @@ def __getattr__(name: str) -> Any:
# Lazy load bedrock_tool_name_mappings instance
if name == "bedrock_tool_name_mappings":
from ._lazy_imports import _get_litellm_globals
from ._lazy_imports import get_litellm_globals
_globals = _get_litellm_globals()
_globals = get_litellm_globals()
# Check if already cached
if "bedrock_tool_name_mappings" not in _globals:
from .llms.bedrock.chat.invoke_handler import (
@ -2172,9 +2189,9 @@ def __getattr__(name: str) -> Any:
# Lazy load AzureOpenAIError exception class
if name == "AzureOpenAIError":
from ._lazy_imports import _get_litellm_globals
from ._lazy_imports import get_litellm_globals
_globals = _get_litellm_globals()
_globals = get_litellm_globals()
# Check if already cached
if "AzureOpenAIError" not in _globals:
from .llms.azure.common_utils import AzureOpenAIError as _AzureOpenAIError
@ -2184,9 +2201,9 @@ def __getattr__(name: str) -> Any:
# Lazy load openaiOSeriesConfig instance
if name == "openaiOSeriesConfig":
from ._lazy_imports import _get_litellm_globals
from ._lazy_imports import get_litellm_globals
_globals = _get_litellm_globals()
_globals = get_litellm_globals()
if "openaiOSeriesConfig" not in _globals:
# Import the config class and instantiate it
config_class = __getattr__("OpenAIOSeriesConfig")
@ -2194,7 +2211,7 @@ def __getattr__(name: str) -> Any:
return _globals["openaiOSeriesConfig"]
# Lazy load other config instances
_config_instances = {
_config_instances: Final = {
"openAIGPTConfig": "OpenAIGPTConfig",
"openAIGPTAudioConfig": "OpenAIGPTAudioConfig",
"openAIGPT5Config": "OpenAIGPT5Config",
@ -2202,9 +2219,9 @@ def __getattr__(name: str) -> Any:
"nvidiaNimEmbeddingConfig": "NvidiaNimEmbeddingConfig",
}
if name in _config_instances:
from ._lazy_imports import _get_litellm_globals
from ._lazy_imports import get_litellm_globals
_globals = _get_litellm_globals()
_globals = get_litellm_globals()
if name not in _globals:
# Import the config class and instantiate it
config_class = __getattr__(_config_instances[name])
@ -2217,9 +2234,9 @@ def __getattr__(name: str) -> Any:
# Lazy load provider_list
if name == "provider_list":
from ._lazy_imports import _get_litellm_globals
from ._lazy_imports import get_litellm_globals
_globals = _get_litellm_globals()
_globals = get_litellm_globals()
# Check if already cached
if "provider_list" not in _globals:
# LlmProviders is eagerly imported above, so we can import it directly
@ -2230,33 +2247,33 @@ def __getattr__(name: str) -> Any:
# Lazy load priority_reservation_settings instance
if name == "priority_reservation_settings":
from ._lazy_imports import _get_litellm_globals
from ._lazy_imports import get_litellm_globals
_globals = _get_litellm_globals()
_globals = get_litellm_globals()
# Check if already cached
if "priority_reservation_settings" not in _globals:
# Import the class and instantiate it
PriorityReservationSettings = __getattr__("PriorityReservationSettings")
PriorityReservationSettings: Final = __getattr__("PriorityReservationSettings")
_globals["priority_reservation_settings"] = PriorityReservationSettings()
return _globals["priority_reservation_settings"]
# Lazy load logging_callback_manager instance
if name == "logging_callback_manager":
from ._lazy_imports import _get_litellm_globals
from ._lazy_imports import get_litellm_globals
_globals = _get_litellm_globals()
_globals = get_litellm_globals()
# Check if already cached
if "logging_callback_manager" not in _globals:
# Import the class and instantiate it
LoggingCallbackManager = __getattr__("LoggingCallbackManager")
LoggingCallbackManager: Final = __getattr__("LoggingCallbackManager")
_globals["logging_callback_manager"] = LoggingCallbackManager()
return _globals["logging_callback_manager"]
# Lazy load _service_logger module
if name == "_service_logger":
from ._lazy_imports import _get_litellm_globals
from ._lazy_imports import get_litellm_globals
_globals = _get_litellm_globals()
_globals = get_litellm_globals()
# Check if already cached
if "_service_logger" not in _globals:
# Import the module lazily

View file

@ -7,7 +7,8 @@ asyncio task and cannot be injected via HTTP request bodies.
"""
from contextvars import ContextVar
from typing import Final
# When True, suppresses async logging and billing for internal sub-calls
# (e.g., emulated file-search steps that make nested LLM calls).
is_internal_call: ContextVar[bool] = ContextVar("is_internal_call", default=False)
is_internal_call: Final[ContextVar[bool]] = ContextVar("is_internal_call", default=False)

View file

@ -17,43 +17,44 @@ until they're actually needed.
import importlib
import sys
from typing import Any, Optional, cast, Callable
from collections.abc import Callable
from typing import Any, Final, cast
# Import all the data structures that define what can be lazy-loaded
# These are just lists of names and maps of where to find them
from ._lazy_imports_registry import (
# Name tuples
COST_CALCULATOR_NAMES,
LITELLM_LOGGING_NAMES,
UTILS_NAMES,
TOKEN_COUNTER_NAMES,
LLM_CLIENT_CACHE_NAMES,
BEDROCK_TYPES_NAMES,
TYPES_UTILS_NAMES,
CACHING_NAMES,
HTTP_HANDLER_NAMES,
DOTPROMPT_NAMES,
LLM_CONFIG_NAMES,
TYPES_NAMES,
LLM_PROVIDER_LOGIC_NAMES,
UTILS_MODULE_NAMES,
# Import maps
_UTILS_IMPORT_MAP,
_COST_CALCULATOR_IMPORT_MAP,
_TYPES_UTILS_IMPORT_MAP,
_TOKEN_COUNTER_IMPORT_MAP,
_BEDROCK_TYPES_IMPORT_MAP,
_CACHING_IMPORT_MAP,
_LITELLM_LOGGING_IMPORT_MAP,
_COST_CALCULATOR_IMPORT_MAP,
_DOTPROMPT_IMPORT_MAP,
_TYPES_IMPORT_MAP,
_LITELLM_LOGGING_IMPORT_MAP,
_LLM_CONFIGS_IMPORT_MAP,
_LLM_PROVIDER_LOGIC_IMPORT_MAP,
_TOKEN_COUNTER_IMPORT_MAP,
_TYPES_IMPORT_MAP,
_TYPES_UTILS_IMPORT_MAP,
_UTILS_IMPORT_MAP,
_UTILS_MODULE_IMPORT_MAP,
# Name tuples
BEDROCK_TYPES_NAMES,
CACHING_NAMES,
COST_CALCULATOR_NAMES,
DOTPROMPT_NAMES,
HTTP_HANDLER_NAMES,
LITELLM_LOGGING_NAMES,
LLM_CLIENT_CACHE_NAMES,
LLM_CONFIG_NAMES,
LLM_PROVIDER_LOGIC_NAMES,
TOKEN_COUNTER_NAMES,
TYPES_NAMES,
TYPES_UTILS_NAMES,
UTILS_MODULE_NAMES,
UTILS_NAMES,
)
def _get_litellm_globals() -> dict:
def get_litellm_globals() -> dict:
"""
Get the globals dictionary of the litellm module.
@ -77,7 +78,7 @@ def _get_utils_globals() -> dict:
# They're separate from the main lazy import system because they have specific use cases
# Lazy loader for default encoding - avoids importing heavy tiktoken library at startup
_default_encoding: Optional[Any] = None
_default_encoding: Any | None = None
def _get_default_encoding() -> Any:
@ -99,7 +100,7 @@ def _get_default_encoding() -> Any:
# Lazy loader for get_modified_max_tokens to avoid importing token_counter at module import time
_get_modified_max_tokens_func: Optional[Any] = None
_get_modified_max_tokens_func: Any | None = None
def _get_modified_max_tokens() -> Any:
@ -123,7 +124,7 @@ def _get_modified_max_tokens() -> Any:
# Lazy loader for token_counter to avoid importing token_counter module at module import time
_token_counter_new_func: Optional[Any] = None
_token_counter_new_func: Any | None = None
def _get_token_counter_new() -> Any:
@ -153,7 +154,7 @@ def _get_token_counter_new() -> Any:
# This registry maps attribute names (like "ModelResponse") to handler functions
# It's built once the first time someone accesses a lazy-loaded attribute
# Example: {"ModelResponse": _lazy_import_utils, "Cache": _lazy_import_caching, ...}
_LAZY_IMPORT_REGISTRY: Optional[dict[str, Callable[[str], Any]]] = None
_LAZY_IMPORT_REGISTRY: dict[str, Callable[[str], Any]] | None = None
def _get_lazy_import_registry() -> dict[str, Callable[[str], Any]]:
@ -232,7 +233,7 @@ def _generic_lazy_import(name: str, import_map: dict[str, tuple[str, str]], cate
raise AttributeError(f"{category} lazy import: unknown attribute {name!r}")
# Step 2: Get the cache (where we store imported things)
_globals = _get_litellm_globals()
_globals: Final = get_litellm_globals()
# Step 3: If we've already imported it, just return the cached version
if name in _globals:
@ -254,7 +255,7 @@ def _generic_lazy_import(name: str, import_map: dict[str, tuple[str, str]], cate
# Step 6: Get the actual attribute from the module
# Example: getattr(utils_module, "ModelResponse") returns the ModelResponse class
value = getattr(module, attr_name)
value: Final = getattr(module, attr_name)
# Step 7: Cache it so we don't have to import again next time
_globals[name] = value
@ -331,14 +332,14 @@ def _lazy_import_utils_module(name: str) -> Any:
Handler for utils module lazy imports.
This uses a custom implementation because utils module needs to use
_get_utils_globals() instead of _get_litellm_globals() for caching.
_get_utils_globals() instead of get_litellm_globals() for caching.
"""
# Check if this attribute exists in our map
if name not in _UTILS_MODULE_IMPORT_MAP:
raise AttributeError(f"Utils module lazy import: unknown attribute {name!r}")
# Get the cache (where we store imported things) - use utils globals
_globals = _get_utils_globals()
_globals: Final = _get_utils_globals()
# If we've already imported it, just return the cached version
if name in _globals:
@ -354,7 +355,7 @@ def _lazy_import_utils_module(name: str) -> Any:
module = importlib.import_module(module_path)
# Get the actual attribute from the module
value = getattr(module, attr_name)
value: Final = getattr(module, attr_name)
# Cache it so we don't have to import again next time
_globals[name] = value
@ -378,15 +379,15 @@ def _lazy_import_llm_client_cache(name: str) -> Any:
- "in_memory_llm_clients_cache" is a singleton instance of that class
So we need custom logic to handle both cases.
"""
_globals = _get_litellm_globals()
_globals: Final = get_litellm_globals()
# If already cached, return it
if name in _globals:
return _globals[name]
# Import the class
module = importlib.import_module("litellm.caching.llm_caching_handler")
LLMClientCache = getattr(module, "LLMClientCache")
module: Final = importlib.import_module("litellm.caching.llm_caching_handler")
LLMClientCache: Final = getattr(module, "LLMClientCache")
# If they want the class itself, return it
if name == "LLMClientCache":
@ -395,7 +396,7 @@ def _lazy_import_llm_client_cache(name: str) -> Any:
# If they want the singleton instance, create it (only once)
if name == "in_memory_llm_clients_cache":
instance = LLMClientCache()
instance: Final = LLMClientCache()
_globals["in_memory_llm_clients_cache"] = instance
return instance
@ -411,7 +412,7 @@ def _lazy_import_http_handlers(name: str) -> Any:
- They need configuration (timeout, etc.) from the module globals
- They use factory functions instead of direct instantiation
"""
_globals = _get_litellm_globals()
_globals: Final = get_litellm_globals()
if name == "module_level_aclient":
# Create an async HTTP client using the factory function
@ -419,11 +420,11 @@ def _lazy_import_http_handlers(name: str) -> Any:
# Get timeout from module config (if set)
timeout = _globals.get("request_timeout")
params = {"timeout": timeout, "client_alias": "module level aclient"}
params: Final = {"timeout": timeout, "client_alias": "module level aclient"}
# Create the client instance
provider_id = cast(Any, "litellm_module_level_client")
async_client = get_async_httpx_client(
provider_id: Final = cast(Any, "litellm_module_level_client")
async_client: Final = get_async_httpx_client(
llm_provider=provider_id,
params=params,
)
@ -437,7 +438,7 @@ def _lazy_import_http_handlers(name: str) -> Any:
from litellm.llms.custom_httpx.http_handler import HTTPHandler
timeout = _globals.get("request_timeout")
sync_client = HTTPHandler(timeout=timeout)
sync_client: Final = HTTPHandler(timeout=timeout)
# Cache it
_globals["module_level_client"] = sync_client

View file

@ -5,21 +5,23 @@ This module contains all the name tuples and import maps used by the lazy import
Separated from the handler functions for better organization.
"""
from typing import Final
# Cost calculator names that support lazy loading via _lazy_import_cost_calculator
COST_CALCULATOR_NAMES = (
COST_CALCULATOR_NAMES: Final = (
"completion_cost",
"cost_per_token",
"response_cost_calculator",
)
# Litellm logging names that support lazy loading via _lazy_import_litellm_logging
LITELLM_LOGGING_NAMES = (
LITELLM_LOGGING_NAMES: Final = (
"Logging",
"modify_integration",
)
# Utils names that support lazy loading via _lazy_import_utils
UTILS_NAMES = (
UTILS_NAMES: Final = (
"exception_type",
"get_optional_params",
"get_response_string",
@ -66,20 +68,20 @@ UTILS_NAMES = (
)
# Token counter names that support lazy loading via _lazy_import_token_counter
TOKEN_COUNTER_NAMES = ("get_modified_max_tokens",)
TOKEN_COUNTER_NAMES: Final = ("get_modified_max_tokens",)
# LLM client cache names that support lazy loading via _lazy_import_llm_client_cache
LLM_CLIENT_CACHE_NAMES = (
LLM_CLIENT_CACHE_NAMES: Final = (
"LLMClientCache",
"in_memory_llm_clients_cache",
)
# Bedrock type names that support lazy loading via _lazy_import_bedrock_types
BEDROCK_TYPES_NAMES = ("COHERE_EMBEDDING_INPUT_TYPES",)
BEDROCK_TYPES_NAMES: Final = ("COHERE_EMBEDDING_INPUT_TYPES",)
# Common types from litellm.types.utils that support lazy loading via
# _lazy_import_types_utils
TYPES_UTILS_NAMES = (
TYPES_UTILS_NAMES: Final = (
"ImageObject",
"BudgetConfig",
"all_litellm_params",
@ -92,7 +94,7 @@ TYPES_UTILS_NAMES = (
)
# Caching / cache classes that support lazy loading via _lazy_import_caching
CACHING_NAMES = (
CACHING_NAMES: Final = (
"Cache",
"DualCache",
"RedisCache",
@ -100,20 +102,20 @@ CACHING_NAMES = (
)
# HTTP handler names that support lazy loading via _lazy_import_http_handlers
HTTP_HANDLER_NAMES = (
HTTP_HANDLER_NAMES: Final = (
"module_level_aclient",
"module_level_client",
)
# Dotprompt integration names that support lazy loading via _lazy_import_dotprompt
DOTPROMPT_NAMES = (
DOTPROMPT_NAMES: Final = (
"global_prompt_manager",
"global_prompt_directory",
"set_global_prompt_directory",
)
# LLM config classes that support lazy loading via _lazy_import_llm_configs
LLM_CONFIG_NAMES = (
LLM_CONFIG_NAMES: Final = (
"AmazonConverseConfig",
"OpenAILikeChatConfig",
"GaladrielChatConfig",
@ -328,7 +330,7 @@ LLM_CONFIG_NAMES = (
)
# Types that support lazy loading via _lazy_import_types
TYPES_NAMES = (
TYPES_NAMES: Final = (
"GuardrailItem",
"DefaultTeamSSOParams",
"LiteLLM_UpperboundKeyGenerateParams",
@ -344,14 +346,14 @@ TYPES_NAMES = (
)
# LLM provider logic names that support lazy loading via _lazy_import_llm_provider_logic
LLM_PROVIDER_LOGIC_NAMES = (
LLM_PROVIDER_LOGIC_NAMES: Final = (
"get_llm_provider",
"remove_index_from_tool_calls",
)
# Utils module names that support lazy loading via _lazy_import_utils_module
# These are attributes accessed from litellm.utils module
UTILS_MODULE_NAMES = (
UTILS_MODULE_NAMES: Final = (
"encoding",
"BaseVectorStore",
"CredentialAccessor",
@ -423,7 +425,7 @@ UTILS_MODULE_NAMES = (
)
# Import maps for registry pattern - reduces repetition
_UTILS_IMPORT_MAP = {
_UTILS_IMPORT_MAP: Final = {
"exception_type": (".utils", "exception_type"),
"get_optional_params": (".utils", "get_optional_params"),
"get_response_string": (".utils", "get_response_string"),
@ -478,13 +480,13 @@ _UTILS_IMPORT_MAP = {
),
}
_COST_CALCULATOR_IMPORT_MAP = {
_COST_CALCULATOR_IMPORT_MAP: Final = {
"completion_cost": (".cost_calculator", "completion_cost"),
"cost_per_token": (".cost_calculator", "cost_per_token"),
"response_cost_calculator": (".cost_calculator", "response_cost_calculator"),
}
_TYPES_UTILS_IMPORT_MAP = {
_TYPES_UTILS_IMPORT_MAP: Final = {
"ImageObject": (".types.utils", "ImageObject"),
"BudgetConfig": (".types.utils", "BudgetConfig"),
"all_litellm_params": (".types.utils", "all_litellm_params"),
@ -496,28 +498,28 @@ _TYPES_UTILS_IMPORT_MAP = {
"GenericStreamingChunk": (".types.utils", "GenericStreamingChunk"),
}
_TOKEN_COUNTER_IMPORT_MAP = {
_TOKEN_COUNTER_IMPORT_MAP: Final = {
"get_modified_max_tokens": (
"litellm.litellm_core_utils.token_counter",
"get_modified_max_tokens",
),
}
_BEDROCK_TYPES_IMPORT_MAP = {
_BEDROCK_TYPES_IMPORT_MAP: Final = {
"COHERE_EMBEDDING_INPUT_TYPES": (
"litellm.types.llms.bedrock",
"COHERE_EMBEDDING_INPUT_TYPES",
),
}
_CACHING_IMPORT_MAP = {
_CACHING_IMPORT_MAP: Final = {
"Cache": ("litellm.caching.caching", "Cache"),
"DualCache": ("litellm.caching.caching", "DualCache"),
"RedisCache": ("litellm.caching.caching", "RedisCache"),
"InMemoryCache": ("litellm.caching.caching", "InMemoryCache"),
}
_LITELLM_LOGGING_IMPORT_MAP = {
_LITELLM_LOGGING_IMPORT_MAP: Final = {
"Logging": ("litellm.litellm_core_utils.litellm_logging", "Logging"),
"modify_integration": (
"litellm.litellm_core_utils.litellm_logging",
@ -525,7 +527,7 @@ _LITELLM_LOGGING_IMPORT_MAP = {
),
}
_DOTPROMPT_IMPORT_MAP = {
_DOTPROMPT_IMPORT_MAP: Final = {
"global_prompt_manager": (
"litellm.integrations.dotprompt",
"global_prompt_manager",
@ -540,7 +542,7 @@ _DOTPROMPT_IMPORT_MAP = {
),
}
_TYPES_IMPORT_MAP = {
_TYPES_IMPORT_MAP: Final = {
"GuardrailItem": ("litellm.types.guardrails", "GuardrailItem"),
"DefaultTeamSSOParams": (
"litellm.types.proxy.management_endpoints.ui_sso",
@ -569,7 +571,7 @@ _TYPES_IMPORT_MAP = {
),
}
_LLM_PROVIDER_LOGIC_IMPORT_MAP = {
_LLM_PROVIDER_LOGIC_IMPORT_MAP: Final = {
"get_llm_provider": (
"litellm.litellm_core_utils.get_llm_provider_logic",
"get_llm_provider",
@ -580,7 +582,7 @@ _LLM_PROVIDER_LOGIC_IMPORT_MAP = {
),
}
_LLM_CONFIGS_IMPORT_MAP = {
_LLM_CONFIGS_IMPORT_MAP: Final = {
"AmazonConverseConfig": (
".llms.bedrock.chat.converse_transformation",
"AmazonConverseConfig",
@ -1215,7 +1217,7 @@ _LLM_CONFIGS_IMPORT_MAP = {
}
# Import map for utils module lazy imports
_UTILS_MODULE_IMPORT_MAP = {
_UTILS_MODULE_IMPORT_MAP: Final = {
"encoding": ("litellm.main", "encoding"),
"BaseVectorStore": (
"litellm.integrations.vector_store_integrations.base_vector_store",
@ -1459,32 +1461,30 @@ _UTILS_MODULE_IMPORT_MAP = {
# Export all name tuples and import maps for use in _lazy_imports.py
__all__ = [
# Name tuples
"COST_CALCULATOR_NAMES",
"LITELLM_LOGGING_NAMES",
"UTILS_NAMES",
"TOKEN_COUNTER_NAMES",
"LLM_CLIENT_CACHE_NAMES",
"BEDROCK_TYPES_NAMES",
"TYPES_UTILS_NAMES",
"CACHING_NAMES",
"HTTP_HANDLER_NAMES",
"COST_CALCULATOR_NAMES",
"DOTPROMPT_NAMES",
"HTTP_HANDLER_NAMES",
"LITELLM_LOGGING_NAMES",
"LLM_CLIENT_CACHE_NAMES",
"LLM_CONFIG_NAMES",
"TYPES_NAMES",
"LLM_PROVIDER_LOGIC_NAMES",
"TOKEN_COUNTER_NAMES",
"TYPES_NAMES",
"TYPES_UTILS_NAMES",
"UTILS_MODULE_NAMES",
# Import maps
"_UTILS_IMPORT_MAP",
"_COST_CALCULATOR_IMPORT_MAP",
"_TYPES_UTILS_IMPORT_MAP",
"_TOKEN_COUNTER_IMPORT_MAP",
"UTILS_NAMES",
"_BEDROCK_TYPES_IMPORT_MAP",
"_CACHING_IMPORT_MAP",
"_LITELLM_LOGGING_IMPORT_MAP",
"_COST_CALCULATOR_IMPORT_MAP",
"_DOTPROMPT_IMPORT_MAP",
"_TYPES_IMPORT_MAP",
"_LITELLM_LOGGING_IMPORT_MAP",
"_LLM_CONFIGS_IMPORT_MAP",
"_LLM_PROVIDER_LOGIC_IMPORT_MAP",
"_TOKEN_COUNTER_IMPORT_MAP",
"_TYPES_IMPORT_MAP",
"_TYPES_UTILS_IMPORT_MAP",
"_UTILS_IMPORT_MAP",
"_UTILS_MODULE_IMPORT_MAP",
]

View file

@ -4,11 +4,11 @@ import os
import sys
from datetime import datetime
from logging import Formatter
from typing import Any, Dict, Optional
from typing import Any, Final
from litellm.litellm_core_utils.secret_redaction import redact_string
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.secret_redaction import redact_string
set_verbose = False
@ -17,7 +17,7 @@ if set_verbose is True:
"`litellm.set_verbose` is deprecated. Please set `os.environ['LITELLM_LOG'] = 'DEBUG'` for debug logs."
)
_ENABLE_SECRET_REDACTION = os.getenv("LITELLM_DISABLE_REDACT_SECRETS", "").lower() != "true"
_ENABLE_SECRET_REDACTION: Final = os.getenv("LITELLM_DISABLE_REDACT_SECRETS", "").lower() != "true"
def _redact_string(value: str) -> str:
@ -74,19 +74,19 @@ class SecretRedactionFilter(logging.Filter):
return True
_secret_filter = SecretRedactionFilter()
_secret_filter: Final = SecretRedactionFilter()
json_logs = bool(os.getenv("JSON_LOGS", False))
# Create a handler for the logger (you may need to adapt this based on your needs)
log_level = os.getenv("LITELLM_LOG", "DEBUG")
numeric_level: str = getattr(logging, log_level.upper())
handler = logging.StreamHandler()
log_level: Final = os.getenv("LITELLM_LOG", "DEBUG")
numeric_level: Final[str] = getattr(logging, log_level.upper())
handler: Final = logging.StreamHandler()
handler.setLevel(numeric_level)
handler.addFilter(_secret_filter)
def _try_parse_json_message(message: str) -> Optional[Dict[str, Any]]:
def _try_parse_json_message(message: str) -> dict[str, Any] | None:
"""
Try to parse a log message as JSON. Returns parsed dict if valid, else None.
Handles messages that are entirely valid JSON (e.g. json.dumps output).
@ -94,16 +94,16 @@ def _try_parse_json_message(message: str) -> Optional[Dict[str, Any]]:
"""
if not message or not isinstance(message, str):
return None
msg_stripped = message.strip()
msg_stripped: Final = message.strip()
if not (msg_stripped.startswith("{") or msg_stripped.startswith("[")):
return None
parsed = safe_json_loads(message, default=None)
parsed: Final = safe_json_loads(message, default=None)
if parsed is None or not isinstance(parsed, dict):
return None
return parsed
def _try_parse_embedded_python_dict(message: str) -> Optional[Dict[str, Any]]:
def _try_parse_embedded_python_dict(message: str) -> dict[str, Any] | None:
"""
Try to find and parse a Python dict repr (e.g. str(d) or repr(d)) embedded in
the message. Handles patterns like:
@ -144,21 +144,21 @@ def _get_standard_record_attrs() -> frozenset:
return frozenset(logging.LogRecord("", 0, "", 0, "", (), None).__dict__.keys())
_STANDARD_RECORD_ATTRS = _get_standard_record_attrs()
_STANDARD_RECORD_ATTRS: Final = _get_standard_record_attrs()
class JsonFormatter(Formatter):
def __init__(self):
super(JsonFormatter, self).__init__()
super().__init__()
def formatTime(self, record, datefmt=None):
# Use datetime to format the timestamp in ISO 8601 format
dt = datetime.fromtimestamp(record.created)
dt: Final = datetime.fromtimestamp(record.created)
return dt.isoformat()
def format(self, record):
message_str = record.getMessage()
json_record: Dict[str, Any] = {
message_str: Final = record.getMessage()
json_record: Final[dict[str, Any]] = {
"message": message_str,
"level": record.levelname,
"timestamp": self.formatTime(record),
@ -193,13 +193,13 @@ class JsonFormatter(Formatter):
# Function to set up exception handlers for JSON logging
def _setup_json_exception_handlers(formatter):
# Create a handler with JSON formatting for exceptions
error_handler = logging.StreamHandler()
error_handler: Final = logging.StreamHandler()
error_handler.setFormatter(formatter)
error_handler.addFilter(_secret_filter)
# Setup excepthook for uncaught exceptions
def json_excepthook(exc_type, exc_value, exc_traceback):
record = logging.LogRecord(
record: Final = logging.LogRecord(
name="LiteLLM",
level=logging.ERROR,
pathname="",
@ -217,10 +217,10 @@ def _setup_json_exception_handlers(formatter):
import asyncio
def async_json_exception_handler(loop, context):
exception = context.get("exception")
exception: Final = context.get("exception")
if exception:
exc_type = type(exception)
record = logging.LogRecord(
exc_type: Final = type(exception)
record: Final = logging.LogRecord(
name="LiteLLM",
level=logging.ERROR,
pathname="",
@ -243,7 +243,7 @@ if json_logs:
handler.setFormatter(JsonFormatter())
_setup_json_exception_handlers(JsonFormatter())
else:
formatter = logging.Formatter(
formatter: Final = logging.Formatter(
"\033[92m%(asctime)s - %(name)s:%(levelname)s\033[0m: %(filename)s:%(lineno)s - %(message)s",
datefmt="%H:%M:%S",
)
@ -263,20 +263,54 @@ verbose_logger.addHandler(handler)
def _suppress_loggers():
"""Suppress noisy loggers at INFO level"""
# Suppress httpx request logging at INFO level
httpx_logger = logging.getLogger("httpx")
httpx_logger: Final = logging.getLogger("httpx")
httpx_logger.setLevel(logging.WARNING)
# Suppress APScheduler logging at INFO level
apscheduler_executors_logger = logging.getLogger("apscheduler.executors.default")
apscheduler_executors_logger: Final = logging.getLogger("apscheduler.executors.default")
apscheduler_executors_logger.setLevel(logging.WARNING)
apscheduler_scheduler_logger = logging.getLogger("apscheduler.scheduler")
apscheduler_scheduler_logger: Final = logging.getLogger("apscheduler.scheduler")
apscheduler_scheduler_logger.setLevel(logging.WARNING)
_REDACTED_THIRD_PARTY_LOGGERS: Final[tuple[str, ...]] = (
"apscheduler.executors.default",
"apscheduler.scheduler",
"asyncio",
"backoff",
"httpx",
"uvicorn.error",
)
def _redact_third_party_loggers() -> None:
"""Extend secret redaction to records litellm does not emit directly.
litellm's own loggers are covered by the filter on their shared handler, but a
litellm value can also reach a log record through a dependency that logs on its
own logger. Those records never pass through a litellm handler.
The filter is attached to each emitting logger rather than to the root logger or
to root's handlers. `Logger.handle` applies the emitting logger's filters before
any handler runs, so redaction happens once, at the earliest point in the
record's life, and covers every downstream handler regardless of who owns it.
The alternatives do not hold: `callHandlers` consults ancestors for handlers but
never for filters, so a filter on the root logger never sees these records at
all, and a filter on a root handler only covers that one handler, leaving
handlers registered earlier or on the emitting logger itself untouched.
Each name is the exact logger a dependency emits on; a parent name would not
cover its children, for the same reason the root logger does not.
"""
for name in _REDACTED_THIRD_PARTY_LOGGERS:
logging.getLogger(name).addFilter(_secret_filter)
# Call the suppression function
_suppress_loggers()
_redact_third_party_loggers()
ALL_LOGGERS = [
ALL_LOGGERS: Final = [
logging.getLogger(),
verbose_logger,
verbose_router_logger,
@ -293,11 +327,11 @@ def _get_loggers_to_initialize():
"""
import litellm
loggers = list(ALL_LOGGERS)
loggers: Final = list(ALL_LOGGERS)
# Add langfuse logger if langfuse is being used as a callback
langfuse_callbacks = {"langfuse", "langfuse_otel"}
all_callbacks = set(litellm.success_callback + litellm.failure_callback)
langfuse_callbacks: Final = {"langfuse", "langfuse_otel"}
all_callbacks: Final = set(litellm.success_callback + litellm.failure_callback)
if langfuse_callbacks & all_callbacks:
loggers.append(logging.getLogger("langfuse"))
@ -325,12 +359,12 @@ def _get_uvicorn_json_log_config():
This ensures that uvicorn's access logs, error logs, and all application logs
are formatted as JSON when json_logs is enabled.
"""
json_formatter_class = "litellm._logging.JsonFormatter"
json_formatter_class: Final = "litellm._logging.JsonFormatter"
# Use the module-level log_level variable for consistency
uvicorn_log_level = log_level.upper()
uvicorn_log_level: Final = log_level.upper()
log_config = {
log_config: Final = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
@ -384,7 +418,7 @@ def _turn_on_json():
- Adds a JSON formatter to all loggers
"""
handler = logging.StreamHandler()
handler: Final = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
_initialize_loggers_with_handler(handler)
# Set up exception handlers

View file

@ -12,10 +12,11 @@ import json
# s/o [@Frank Colson](https://www.linkedin.com/in/frank-colson-422b9b183/) for this redis implementation
import os
from typing import Callable, List, Optional, Union
from collections.abc import Callable
from typing import Final
import redis # type: ignore
import redis.asyncio as async_redis # type: ignore
import redis
import redis.asyncio as async_redis
from litellm import get_secret, get_secret_str
from litellm._redis_credential_provider import (
@ -32,20 +33,20 @@ from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
from ._logging import verbose_logger
AZURE_REDIS_SCOPE = "https://redis.azure.com/.default"
AZURE_REDIS_SCOPE: Final = "https://redis.azure.com/.default"
def _get_redis_kwargs():
arg_spec = inspect.getfullargspec(redis.Redis)
arg_spec: Final = inspect.getfullargspec(redis.Redis)
# Only allow primitive arguments
exclude_args = {
exclude_args: Final = {
"self",
"connection_pool",
"retry",
}
include_args = {
include_args: Final = {
"url",
"redis_connect_func",
"gcp_service_account",
@ -56,7 +57,7 @@ def _get_redis_kwargs():
"azure_client_secret",
}
available_args = {x for x in arg_spec.args if x not in exclude_args} | include_args
available_args: Final = {x for x in arg_spec.args if x not in exclude_args} | include_args
return available_args
@ -76,7 +77,7 @@ def _init_arg_names(cls: type) -> frozenset[str]:
)
def _get_redis_url_kwargs(client: Optional[type] = None) -> tuple[str, ...]:
def _get_redis_url_kwargs(client: type | None = None) -> tuple[str, ...]:
"""Connection kwargs that redis-py forwards from ``from_url`` down to the connection.
``from_url`` is declared as ``(cls, url, **kwargs)``, so introspecting it yields no
@ -92,9 +93,9 @@ def _get_redis_url_kwargs(client: Optional[type] = None) -> tuple[str, ...]:
"""
if client is None:
client = redis.Redis
connection_cls = async_redis.Connection if client is async_redis.Redis else redis.Connection
connection_cls: Final = async_redis.Connection if client is async_redis.Redis else redis.Connection
exclude_args = frozenset(
exclude_args: Final = frozenset(
{
"self",
"connection_pool",
@ -103,7 +104,7 @@ def _get_redis_url_kwargs(client: Optional[type] = None) -> tuple[str, ...]:
)
# Only allow primitive arguments
include_args = ("url", "max_connections")
include_args: Final = ("url", "max_connections")
return tuple(x for x in _init_arg_names(connection_cls) if x not in exclude_args) + include_args
@ -111,10 +112,10 @@ def _get_redis_url_kwargs(client: Optional[type] = None) -> tuple[str, ...]:
def _get_redis_cluster_kwargs(client=None):
if client is None:
client = redis.Redis.from_url
arg_spec = inspect.getfullargspec(redis.RedisCluster)
arg_spec: Final = inspect.getfullargspec(redis.RedisCluster)
# Only allow primitive arguments
exclude_args = {"self", "connection_pool", "retry", "host", "port", "startup_nodes"}
exclude_args: Final = {"self", "connection_pool", "retry", "host", "port", "startup_nodes"}
available_args = {x for x in arg_spec.args if x not in exclude_args}
available_args |= {
@ -142,17 +143,17 @@ def _get_redis_cluster_kwargs(client=None):
def _get_redis_env_kwarg_mapping():
PREFIX = "REDIS_"
PREFIX: Final = "REDIS_"
return {f"{PREFIX}{x.upper()}": x for x in _get_redis_kwargs()}
def _redis_kwargs_from_environment():
mapping = _get_redis_env_kwarg_mapping()
mapping: Final = _get_redis_env_kwarg_mapping()
return_dict = {}
return_dict: Final = {}
for k, v in mapping.items():
value = get_secret(k, default_value=None) # type: ignore
value = get_secret(k, default_value=None)
if value is not None:
return_dict[v] = value
return return_dict
@ -160,7 +161,7 @@ def _redis_kwargs_from_environment():
def create_gcp_iam_redis_connect_func(
service_account: str,
ssl_ca_certs: Optional[str] = None,
ssl_ca_certs: str | None = None,
) -> Callable:
"""
Creates a custom Redis connection function for GCP IAM authentication.
@ -183,7 +184,7 @@ def create_gcp_iam_redis_connect_func(
self._parser.on_connect(self)
auth_args = (_generate_gcp_iam_access_token(service_account),)
auth_args: Final = (_generate_gcp_iam_access_token(service_account),)
self.send_command("AUTH", *auth_args, check_health=False)
try:
@ -203,9 +204,9 @@ def create_gcp_iam_redis_connect_func(
def _build_azure_credential(
azure_client_id: Optional[str] = None,
azure_tenant_id: Optional[str] = None,
azure_client_secret: Optional[str] = None,
azure_client_id: str | None = None,
azure_tenant_id: str | None = None,
azure_client_secret: str | None = None,
):
"""
Build a long-lived Azure credential object.
@ -224,9 +225,9 @@ def _build_azure_credential(
"azure-identity is required for Azure AD Redis authentication. Install it with: pip install azure-identity"
)
_client_id = azure_client_id or os.environ.get("AZURE_CLIENT_ID")
_tenant_id = azure_tenant_id or os.environ.get("AZURE_TENANT_ID")
_client_secret = azure_client_secret or os.environ.get("AZURE_CLIENT_SECRET")
_client_id: Final = azure_client_id or os.environ.get("AZURE_CLIENT_ID")
_tenant_id: Final = azure_tenant_id or os.environ.get("AZURE_TENANT_ID")
_client_secret: Final = azure_client_secret or os.environ.get("AZURE_CLIENT_SECRET")
if _client_id and _tenant_id and _client_secret:
return ClientSecretCredential(
@ -241,9 +242,9 @@ def _build_azure_credential(
def _generate_azure_ad_redis_token(
azure_client_id: Optional[str] = None,
azure_tenant_id: Optional[str] = None,
azure_client_secret: Optional[str] = None,
azure_client_id: str | None = None,
azure_tenant_id: str | None = None,
azure_client_secret: str | None = None,
) -> str:
"""
One-shot helper that builds a credential and fetches a single Azure AD
@ -253,19 +254,19 @@ def _generate_azure_ad_redis_token(
(``AzureADCredentialProvider``) keep the credential alive across
connections so the Azure SDK's internal cache + silent refresh apply.
"""
credential = _build_azure_credential(
credential: Final = _build_azure_credential(
azure_client_id=azure_client_id,
azure_tenant_id=azure_tenant_id,
azure_client_secret=azure_client_secret,
)
token = credential.get_token(AZURE_REDIS_SCOPE)
token: Final = credential.get_token(AZURE_REDIS_SCOPE)
return token.token
def create_azure_ad_redis_connect_func(
azure_client_id: Optional[str] = None,
azure_tenant_id: Optional[str] = None,
azure_client_secret: Optional[str] = None,
azure_client_id: str | None = None,
azure_tenant_id: str | None = None,
azure_client_secret: str | None = None,
) -> Callable:
"""
Creates a custom Redis connection function for Azure AD authentication.
@ -274,7 +275,7 @@ def create_azure_ad_redis_connect_func(
closure) and reused across connections the Azure SDK handles token caching
and silent renewal internally. Only ``get_token`` is called per connection.
"""
credential = _build_azure_credential(
credential: Final = _build_azure_credential(
azure_client_id=azure_client_id,
azure_tenant_id=azure_tenant_id,
azure_client_secret=azure_client_secret,
@ -290,11 +291,11 @@ def create_azure_ad_redis_connect_func(
self._parser.on_connect(self)
access_token = credential.get_token(AZURE_REDIS_SCOPE).token
access_token: Final = credential.get_token(AZURE_REDIS_SCOPE).token
# Only include username when explicitly set — sending AUTH "" <token>
# is invalid for most ACL-configured Azure Redis instances.
username = os.environ.get("REDIS_USERNAME", "")
username: Final = os.environ.get("REDIS_USERNAME", "")
if username:
auth_args = (username, access_token)
else:
@ -316,7 +317,7 @@ def create_azure_ad_redis_connect_func(
# AzureADCredentialProvider for refresh-aware token retrieval. The raw
# client_id/tenant_id/secret are intentionally NOT exposed here — the
# credential closure already holds them.
ad_connect._azure_credential = credential # type: ignore[attr-defined]
ad_connect._azure_credential = credential
return ad_connect
@ -350,26 +351,26 @@ def _get_redis_client_logic(**env_overrides):
for k, v in env_overrides.items():
if isinstance(v, str) and v.startswith("os.environ/"):
v = v.replace("os.environ/", "")
value = get_secret(v) # type: ignore
value = get_secret(v)
env_overrides[k] = value
environment_kwargs = _redis_kwargs_from_environment()
environment_kwargs: Final = _redis_kwargs_from_environment()
# An explicitly configured connection target outranks REDIS_URL from the
# environment. Without this, the url branch below strips the caller's
# host/port/password and silently connects to whatever REDIS_URL names.
caller_named_a_target = any(
caller_named_a_target: Final = any(
env_overrides.get(key) is not None for key in ("host", "startup_nodes", "sentinel_nodes")
)
if caller_named_a_target and env_overrides.get("url") is None:
environment_kwargs.pop("url", None)
redis_kwargs = {
redis_kwargs: Final = {
**environment_kwargs,
**env_overrides,
}
_startup_nodes: Optional[Union[str, list]] = redis_kwargs.get("startup_nodes", None) or get_secret( # type: ignore
_startup_nodes: Final[str | list | None] = redis_kwargs.get("startup_nodes", None) or get_secret(
"REDIS_CLUSTER_NODES"
)
@ -380,30 +381,28 @@ def _get_redis_client_logic(**env_overrides):
elif _startup_nodes is None:
redis_kwargs.pop("startup_nodes", None)
_sentinel_nodes: Optional[Union[str, list]] = redis_kwargs.get("sentinel_nodes", None) or get_secret( # type: ignore
_sentinel_nodes: Final[str | list | None] = redis_kwargs.get("sentinel_nodes", None) or get_secret(
"REDIS_SENTINEL_NODES"
)
if _sentinel_nodes is not None and isinstance(_sentinel_nodes, str):
redis_kwargs["sentinel_nodes"] = json.loads(_sentinel_nodes)
_sentinel_password: Optional[str] = redis_kwargs.get("sentinel_password", None) or get_secret_str(
_sentinel_password: Final[str | None] = redis_kwargs.get("sentinel_password", None) or get_secret_str(
"REDIS_SENTINEL_PASSWORD"
)
if _sentinel_password is not None:
redis_kwargs["sentinel_password"] = _sentinel_password
_service_name: Optional[str] = redis_kwargs.get("service_name", None) or get_secret( # type: ignore
"REDIS_SERVICE_NAME"
)
_service_name: Final[str | None] = redis_kwargs.get("service_name", None) or get_secret("REDIS_SERVICE_NAME")
if _service_name is not None:
redis_kwargs["service_name"] = _service_name
# Handle GCP IAM authentication
_gcp_service_account = redis_kwargs.get("gcp_service_account") or get_secret_str("REDIS_GCP_SERVICE_ACCOUNT")
_gcp_ssl_ca_certs = redis_kwargs.get("gcp_ssl_ca_certs") or get_secret_str("REDIS_GCP_SSL_CA_CERTS")
_gcp_service_account: Final = redis_kwargs.get("gcp_service_account") or get_secret_str("REDIS_GCP_SERVICE_ACCOUNT")
_gcp_ssl_ca_certs: Final = redis_kwargs.get("gcp_ssl_ca_certs") or get_secret_str("REDIS_GCP_SSL_CA_CERTS")
if _gcp_service_account is not None:
verbose_logger.debug("Setting up GCP IAM authentication for Redis with service account.")
@ -411,7 +410,7 @@ def _get_redis_client_logic(**env_overrides):
service_account=_gcp_service_account, ssl_ca_certs=_gcp_ssl_ca_certs
)
# Store GCP service account in redis_connect_func for async cluster access
redis_kwargs["redis_connect_func"]._gcp_service_account = _gcp_service_account # type: ignore[attr-defined]
redis_kwargs["redis_connect_func"]._gcp_service_account = _gcp_service_account
# Remove GCP-specific kwargs that shouldn't be passed to Redis client
redis_kwargs.pop("gcp_service_account", None)
@ -422,9 +421,9 @@ def _get_redis_client_logic(**env_overrides):
redis_kwargs["ssl_ca_certs"] = _gcp_ssl_ca_certs
# Handle Azure AD authentication (after GCP IAM block)
_azure_redis_ad_token = redis_kwargs.get("azure_redis_ad_token") or get_secret("REDIS_AZURE_AD_TOKEN")
_azure_redis_ad_token: Final = redis_kwargs.get("azure_redis_ad_token") or get_secret("REDIS_AZURE_AD_TOKEN")
_azure_ad_enabled = _azure_redis_ad_token is not None and str(_azure_redis_ad_token).lower() == "true"
_azure_ad_enabled: Final = _azure_redis_ad_token is not None and str(_azure_redis_ad_token).lower() == "true"
if _azure_ad_enabled and _gcp_service_account is not None:
verbose_logger.warning(
@ -433,9 +432,9 @@ def _get_redis_client_logic(**env_overrides):
)
if _azure_ad_enabled and _gcp_service_account is None:
_azure_client_id = redis_kwargs.get("azure_client_id") or get_secret_str("AZURE_CLIENT_ID")
_azure_tenant_id = redis_kwargs.get("azure_tenant_id") or get_secret_str("AZURE_TENANT_ID")
_azure_client_secret = redis_kwargs.get("azure_client_secret") or get_secret_str("AZURE_CLIENT_SECRET")
_azure_client_id: Final = redis_kwargs.get("azure_client_id") or get_secret_str("AZURE_CLIENT_ID")
_azure_tenant_id: Final = redis_kwargs.get("azure_tenant_id") or get_secret_str("AZURE_TENANT_ID")
_azure_client_secret: Final = redis_kwargs.get("azure_client_secret") or get_secret_str("AZURE_CLIENT_SECRET")
verbose_logger.debug("Setting up Azure AD authentication for Redis.")
redis_kwargs["redis_connect_func"] = create_azure_ad_redis_connect_func(
@ -448,7 +447,7 @@ def _get_redis_client_logic(**env_overrides):
# `create_azure_ad_redis_connect_func`; the raw client_id/tenant_id/secret
# are intentionally NOT exposed on the function to avoid leaking
# credentials via inspection or logging.
redis_kwargs["redis_connect_func"]._azure_redis_ad_token = True # type: ignore[attr-defined]
redis_kwargs["redis_connect_func"]._azure_redis_ad_token = True
# Always remove Azure-specific kwargs that shouldn't be passed to Redis client
redis_kwargs.pop("azure_redis_ad_token", None)
@ -465,9 +464,12 @@ def _get_redis_client_logic(**env_overrides):
redis_kwargs.pop("port", None)
redis_kwargs.pop("db", None)
redis_kwargs.pop("password", None)
elif "startup_nodes" in redis_kwargs and redis_kwargs["startup_nodes"] is not None:
pass
elif "sentinel_nodes" in redis_kwargs and redis_kwargs["sentinel_nodes"] is not None:
elif (
"startup_nodes" in redis_kwargs
and redis_kwargs["startup_nodes"] is not None
or "sentinel_nodes" in redis_kwargs
and redis_kwargs["sentinel_nodes"] is not None
):
pass
elif "host" not in redis_kwargs or redis_kwargs["host"] is None:
raise ValueError("Either 'host' or 'url' must be specified for redis.")
@ -477,7 +479,7 @@ def _get_redis_client_logic(**env_overrides):
def init_redis_cluster(redis_kwargs) -> redis.RedisCluster:
_redis_cluster_nodes_in_env: Optional[str] = get_secret("REDIS_CLUSTER_NODES") # type: ignore
_redis_cluster_nodes_in_env: Final[str | None] = get_secret("REDIS_CLUSTER_NODES")
if _redis_cluster_nodes_in_env is not None:
try:
redis_kwargs["startup_nodes"] = json.loads(_redis_cluster_nodes_in_env)
@ -489,24 +491,24 @@ def init_redis_cluster(redis_kwargs) -> redis.RedisCluster:
verbose_logger.debug("init_redis_cluster: startup nodes are being initialized.")
from redis.cluster import ClusterNode
args = _get_redis_cluster_kwargs()
cluster_kwargs = {}
args: Final = _get_redis_cluster_kwargs()
cluster_kwargs: Final = {}
for arg in redis_kwargs:
if arg in args:
cluster_kwargs[arg] = redis_kwargs[arg]
new_startup_nodes: List[ClusterNode] = []
new_startup_nodes: Final[list[ClusterNode]] = []
for item in redis_kwargs["startup_nodes"]:
new_startup_nodes.append(ClusterNode(**item))
cluster_kwargs.pop("startup_nodes", None)
return redis.RedisCluster(startup_nodes=new_startup_nodes, **cluster_kwargs) # type: ignore
return redis.RedisCluster(startup_nodes=new_startup_nodes, **cluster_kwargs)
def _get_redis_sentinel_connection_kwargs(redis_kwargs: dict) -> dict:
connection_kwargs = {}
args = _get_redis_kwargs()
connection_kwargs: Final = {}
args: Final = _get_redis_kwargs()
for arg in redis_kwargs:
if arg in args:
connection_kwargs[arg] = redis_kwargs[arg]
@ -515,12 +517,12 @@ def _get_redis_sentinel_connection_kwargs(redis_kwargs: dict) -> dict:
def _init_redis_sentinel(redis_kwargs) -> redis.Redis:
sentinel_nodes = redis_kwargs.get("sentinel_nodes")
sentinel_password = redis_kwargs.get("sentinel_password")
service_name = redis_kwargs.get("service_name")
connection_kwargs = _get_redis_sentinel_connection_kwargs(redis_kwargs)
sentinel_nodes: Final = redis_kwargs.get("sentinel_nodes")
sentinel_password: Final = redis_kwargs.get("sentinel_password")
service_name: Final = redis_kwargs.get("service_name")
connection_kwargs: Final = _get_redis_sentinel_connection_kwargs(redis_kwargs)
connection_kwargs.setdefault("socket_timeout", REDIS_SOCKET_TIMEOUT)
sentinel_kwargs = dict(connection_kwargs)
sentinel_kwargs: Final = dict(connection_kwargs)
sentinel_kwargs["password"] = sentinel_password
if not sentinel_nodes or not service_name:
@ -529,7 +531,7 @@ def _init_redis_sentinel(redis_kwargs) -> redis.Redis:
verbose_logger.debug("init_redis_sentinel: sentinel nodes are being initialized.")
# Set up the Sentinel client
sentinel = redis.Sentinel(
sentinel: Final = redis.Sentinel(
sentinel_nodes,
sentinel_kwargs=sentinel_kwargs,
)
@ -540,12 +542,12 @@ def _init_redis_sentinel(redis_kwargs) -> redis.Redis:
def _init_async_redis_sentinel(redis_kwargs) -> async_redis.Redis:
sentinel_nodes = redis_kwargs.get("sentinel_nodes")
sentinel_password = redis_kwargs.get("sentinel_password")
service_name = redis_kwargs.get("service_name")
connection_kwargs = _get_redis_sentinel_connection_kwargs(redis_kwargs)
sentinel_nodes: Final = redis_kwargs.get("sentinel_nodes")
sentinel_password: Final = redis_kwargs.get("sentinel_password")
service_name: Final = redis_kwargs.get("service_name")
connection_kwargs: Final = _get_redis_sentinel_connection_kwargs(redis_kwargs)
connection_kwargs.setdefault("socket_timeout", REDIS_SOCKET_TIMEOUT)
sentinel_kwargs = dict(connection_kwargs)
sentinel_kwargs: Final = dict(connection_kwargs)
sentinel_kwargs["password"] = sentinel_password
if not sentinel_nodes or not service_name:
@ -554,7 +556,7 @@ def _init_async_redis_sentinel(redis_kwargs) -> async_redis.Redis:
verbose_logger.debug("init_redis_sentinel: sentinel nodes are being initialized.")
# Set up the Sentinel client
sentinel = async_redis.Sentinel(
sentinel: Final = async_redis.Sentinel(
sentinel_nodes,
sentinel_kwargs=sentinel_kwargs,
)
@ -565,14 +567,14 @@ def _init_async_redis_sentinel(redis_kwargs) -> async_redis.Redis:
def get_redis_client(**env_overrides):
redis_kwargs = _get_redis_client_logic(**env_overrides)
redis_kwargs: Final = _get_redis_client_logic(**env_overrides)
if "startup_nodes" in redis_kwargs:
return init_redis_cluster(redis_kwargs)
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
args = _get_redis_url_kwargs()
url_kwargs = {}
args: Final = _get_redis_url_kwargs()
url_kwargs: Final = {}
for arg in redis_kwargs:
if arg in args:
url_kwargs[arg] = redis_kwargs[arg]
@ -587,16 +589,16 @@ def get_redis_client(**env_overrides):
def get_redis_async_client(
connection_pool: Optional[async_redis.BlockingConnectionPool] = None,
connection_pool: async_redis.BlockingConnectionPool | None = None,
**env_overrides,
) -> Union[async_redis.Redis, async_redis.RedisCluster]:
redis_kwargs = _get_redis_client_logic(**env_overrides)
) -> async_redis.Redis | async_redis.RedisCluster:
redis_kwargs: Final = _get_redis_client_logic(**env_overrides)
if "startup_nodes" in redis_kwargs:
from redis.cluster import ClusterNode
args = _get_redis_cluster_kwargs()
cluster_kwargs = {}
cluster_kwargs: Final = {}
for arg in redis_kwargs:
if arg in args:
cluster_kwargs[arg] = redis_kwargs[arg]
@ -618,7 +620,7 @@ def get_redis_async_client(
username=os.environ.get("REDIS_USERNAME") or None,
)
new_startup_nodes: List[ClusterNode] = []
new_startup_nodes: Final[list[ClusterNode]] = []
for item in redis_kwargs["startup_nodes"]:
new_startup_nodes.append(ClusterNode(**item))
@ -632,9 +634,9 @@ def get_redis_async_client(
cluster_kwargs.setdefault("socket_keepalive", True)
# Create async RedisCluster with IAM token as password if available
cluster_client = async_redis.RedisCluster(
cluster_client: Final = async_redis.RedisCluster(
startup_nodes=new_startup_nodes,
**cluster_kwargs, # type: ignore
**cluster_kwargs,
)
return cluster_client
@ -643,13 +645,13 @@ def get_redis_async_client(
if connection_pool is not None:
return async_redis.Redis(connection_pool=connection_pool)
args = _get_redis_url_kwargs(client=async_redis.Redis)
url_kwargs = {}
url_kwargs: Final = {}
for arg in redis_kwargs:
if arg in args:
url_kwargs[arg] = redis_kwargs[arg]
else:
verbose_logger.debug(
"REDIS: ignoring argument: {}. Not an allowed async_redis.Redis.from_url arg.".format(arg)
"REDIS: ignoring argument: %s. Not an allowed async_redis.Redis.from_url arg.", arg
)
return async_redis.Redis.from_url(**url_kwargs)
@ -682,16 +684,16 @@ def get_redis_async_client(
def get_redis_connection_pool(
**env_overrides,
) -> Optional[async_redis.BlockingConnectionPool]:
redis_kwargs = _get_redis_client_logic(**env_overrides)
) -> async_redis.BlockingConnectionPool | None:
redis_kwargs: Final = _get_redis_client_logic(**env_overrides)
verbose_logger.debug("get_redis_connection_pool: redis_kwargs", redis_kwargs)
if "startup_nodes" in redis_kwargs:
return None
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
allowed_args = _get_redis_url_kwargs(client=async_redis.Redis)
pool_kwargs = {k: v for k, v in redis_kwargs.items() if k in allowed_args and k != "max_connections"}
allowed_args: Final = _get_redis_url_kwargs(client=async_redis.Redis)
pool_kwargs: Final = {k: v for k, v in redis_kwargs.items() if k in allowed_args and k != "max_connections"}
pool_kwargs["timeout"] = REDIS_CONNECTION_POOL_TIMEOUT
pool_kwargs["url"] = redis_kwargs["url"]
if "max_connections" in redis_kwargs:
@ -707,7 +709,7 @@ def get_redis_connection_pool(
# Wrap GCP / Azure AD auth in a CredentialProvider so pool-managed
# connections re-fetch tokens via the SDK's internal cache + silent refresh
# rather than reusing a single token captured at pool creation.
redis_connect_func = redis_kwargs.pop("redis_connect_func", None)
redis_connect_func: Final = redis_kwargs.pop("redis_connect_func", None)
if redis_connect_func and hasattr(redis_connect_func, "_azure_credential"):
redis_kwargs["credential_provider"] = AzureADCredentialProvider(
redis_connect_func._azure_credential,
@ -734,7 +736,7 @@ def _pretty_print_redis_config(redis_kwargs: dict) -> None:
if not verbose_logger.isEnabledFor(logging.DEBUG):
return
console = Console()
console: Final = Console()
# Initialize the sensitive data masker
masker = SensitiveDataMasker()
@ -743,10 +745,10 @@ def _pretty_print_redis_config(redis_kwargs: dict) -> None:
masked_redis_kwargs = masker.mask_dict(redis_kwargs)
# Create main panel title
title = Text("Redis Configuration", style="bold blue")
title: Final = Text("Redis Configuration", style="bold blue")
# Create configuration table
config_table = Table(
config_table: Final = Table(
title="🔧 Redis Connection Parameters",
show_header=True,
header_style="bold magenta",
@ -783,7 +785,7 @@ def _pretty_print_redis_config(redis_kwargs: dict) -> None:
connection_type = "Redis (URL-based)"
# Create connection type info
info_table = Table(
info_table: Final = Table(
title="📊 Connection Info",
show_header=True,
header_style="bold green",
@ -804,6 +806,6 @@ def _pretty_print_redis_config(redis_kwargs: dict) -> None:
# Fallback to simple logging if rich is not available
masker = SensitiveDataMasker()
masked_redis_kwargs = masker.mask_dict(redis_kwargs)
verbose_logger.info(f"Redis configuration: {masked_redis_kwargs}")
verbose_logger.info("Redis configuration: %s", masked_redis_kwargs)
except Exception as e:
verbose_logger.error(f"Error pretty printing Redis configuration: {e}")
verbose_logger.error("Error pretty printing Redis configuration: %s", e)

View file

@ -1,21 +1,21 @@
import asyncio
import threading
import time
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Final
from redis.credentials import CredentialProvider # type: ignore[attr-defined]
from redis.credentials import CredentialProvider
# Azure AD scope for Redis Cache for Azure.
AZURE_REDIS_SCOPE = "https://redis.azure.com/.default"
AZURE_REDIS_SCOPE: Final = "https://redis.azure.com/.default"
# GCP IAM tokens are valid for 1 hour. Cache for 55 minutes to refresh before expiry.
_GCP_IAM_TOKEN_TTL_SECONDS = 3300
_GCP_IAM_TOKEN_TTL_SECONDS: Final = 3300
# Module-level cache shared across all GCPIAMCredentialProvider instances for the
# same service account, so multiple Redis connections on the same pod share one token.
# Keyed by service_account → (token, expiry_monotonic_timestamp).
_token_cache: Dict[str, Tuple[str, float]] = {}
_token_cache_lock = threading.Lock()
_token_cache: Final[dict[str, tuple[str, float]]] = {}
_token_cache_lock: Final = threading.Lock()
def _generate_gcp_iam_access_token(service_account: str) -> str:
@ -36,12 +36,12 @@ def _generate_gcp_iam_access_token(service_account: str) -> str:
"Install it with: pip install google-cloud-iam"
)
client = iam_credentials_v1.IAMCredentialsClient()
request = iam_credentials_v1.GenerateAccessTokenRequest(
client: Final = iam_credentials_v1.IAMCredentialsClient()
request: Final = iam_credentials_v1.GenerateAccessTokenRequest(
name=service_account,
scope=["https://www.googleapis.com/auth/cloud-platform"],
)
response = client.generate_access_token(request=request)
response: Final = client.generate_access_token(request=request)
return str(response.access_token)
@ -95,12 +95,12 @@ class GCPIAMCredentialProvider(CredentialProvider):
def __init__(self, gcp_service_account: str) -> None:
self._gcp_service_account = gcp_service_account
def get_credentials(self) -> Tuple[str]:
token = _get_cached_gcp_iam_token(self._gcp_service_account)
def get_credentials(self) -> tuple[str]:
token: Final = _get_cached_gcp_iam_token(self._gcp_service_account)
return (token,)
async def get_credentials_async(self) -> Tuple[str]:
token = await asyncio.to_thread(_get_cached_gcp_iam_token, self._gcp_service_account)
async def get_credentials_async(self) -> tuple[str]:
token: Final = await asyncio.to_thread(_get_cached_gcp_iam_token, self._gcp_service_account)
return (token,)
@ -115,18 +115,18 @@ class AzureADCredentialProvider(CredentialProvider):
fail authentication after the initial token expired (~1 hour TTL).
"""
def __init__(self, credential: Any, username: Optional[str] = None) -> None:
def __init__(self, credential: Any, username: str | None = None) -> None:
self._credential = credential
self._username = username
def get_credentials(self) -> Union[Tuple[str], Tuple[str, str]]:
token = self._credential.get_token(AZURE_REDIS_SCOPE).token
def get_credentials(self) -> tuple[str] | tuple[str, str]:
token: Final = self._credential.get_token(AZURE_REDIS_SCOPE).token
if self._username:
return (self._username, token)
return (token,)
async def get_credentials_async(self) -> Union[Tuple[str], Tuple[str, str]]:
token_obj = await asyncio.to_thread(self._credential.get_token, AZURE_REDIS_SCOPE)
async def get_credentials_async(self) -> tuple[str] | tuple[str, str]:
token_obj: Final = await asyncio.to_thread(self._credential.get_token, AZURE_REDIS_SCOPE)
if self._username:
return (self._username, token_obj.token)
return (token_obj.token,)

View file

@ -1,6 +1,6 @@
import asyncio
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Optional, Union
from typing import TYPE_CHECKING, Any, Final
import litellm
from litellm._logging import verbose_logger
@ -16,7 +16,7 @@ if TYPE_CHECKING:
from litellm.proxy._types import UserAPIKeyAuth
Span = Union[_Span, Any]
Span = _Span | Any
OTELClass = OpenTelemetry
else:
Span = Any
@ -24,7 +24,7 @@ else:
UserAPIKeyAuth = Any
def _get_otel_v2_class() -> Optional[type]:
def _get_otel_v2_class() -> type | None:
"""Return the ``OpenTelemetryV2`` class, or ``None`` if the OTel SDK is absent.
Imported lazily: ``litellm.integrations.otel.logger`` imports the OpenTelemetry
@ -54,7 +54,7 @@ class ServiceLogging(CustomLogger):
if "prometheus_system" in litellm.service_callback:
self.prometheusServicesLogger = PrometheusServicesLogger()
def _resolve_otel_service_logger(self, callback: Any) -> Optional[Any]:
def _resolve_otel_service_logger(self, callback: Any) -> Any | None:
"""Resolve the OTel logger (legacy or V2) to emit a service span on.
Returns the logger instance whose ``async_service_*_hook`` should fire for
@ -67,7 +67,7 @@ class ServiceLogging(CustomLogger):
whether the callback is the logger instance itself or the ``"otel"`` string
(which routes to the proxy's registered ``open_telemetry_logger``).
"""
otel_v2_cls = _get_otel_v2_class()
otel_v2_cls: Final = _get_otel_v2_class()
def _is_otel_logger(obj: Any) -> bool:
if isinstance(obj, OpenTelemetry):
@ -88,9 +88,9 @@ class ServiceLogging(CustomLogger):
service: ServiceTypes,
duration: float,
call_type: str,
parent_otel_span: Optional[Span] = None,
start_time: Optional[Union[datetime, float]] = None,
end_time: Optional[Union[float, datetime]] = None,
parent_otel_span: Span | None = None,
start_time: datetime | float | None = None,
end_time: float | datetime | None = None,
):
"""
Handles both sync and async monitoring by checking for existing event loop.
@ -101,7 +101,7 @@ class ServiceLogging(CustomLogger):
try:
# Try to get the current event loop
loop = asyncio.get_event_loop()
loop: Final = asyncio.get_event_loop()
# Check if the loop is running
if loop.is_running():
# If we're in a running loop, create a task
@ -152,10 +152,10 @@ class ServiceLogging(CustomLogger):
service: ServiceTypes,
call_type: str,
duration: float,
parent_otel_span: Optional[Span] = None,
start_time: Optional[Union[datetime, float]] = None,
end_time: Optional[Union[datetime, float]] = None,
event_metadata: Optional[dict] = None,
parent_otel_span: Span | None = None,
start_time: datetime | float | None = None,
end_time: datetime | float | None = None,
event_metadata: dict | None = None,
):
"""
- For counting if the redis, postgres call is successful
@ -163,7 +163,7 @@ class ServiceLogging(CustomLogger):
if self.mock_testing:
self.mock_testing_async_success_hook += 1
payload = ServiceLoggerPayload(
payload: Final = ServiceLoggerPayload(
is_error=False,
error=None,
service=service,
@ -178,7 +178,7 @@ class ServiceLogging(CustomLogger):
# (the V2 logger self-registers its instance even when the string is
# present, unlike V1). Without this guard each such reference emits its own
# span, so a single DB call shows up as duplicate ``postgres ...`` spans.
emitted_otel_logger_ids: set = set()
emitted_otel_logger_ids: Final[set] = set()
for callback in litellm.service_callback:
if callback == "prometheus_system":
await self.init_prometheus_services_logger_if_none()
@ -218,7 +218,6 @@ class ServiceLogging(CustomLogger):
self.prometheusServicesLogger = PrometheusServicesLogger()
elif self.prometheusServicesLogger is None:
self.prometheusServicesLogger = self.prometheusServicesLogger()
return
async def init_datadog_logger_if_none(self):
"""
@ -230,8 +229,6 @@ class ServiceLogging(CustomLogger):
if not hasattr(self, "dd_logger"):
self.dd_logger: DataDogLogger = DataDogLogger()
return
async def init_otel_logger_if_none(self):
"""
initializes otel_logger if it is None or no attribute exists on ServiceLogging Object
@ -246,18 +243,17 @@ class ServiceLogging(CustomLogger):
verbose_logger.warning(
"ServiceLogger: open_telemetry_logger is None or not an instance of OpenTelemetry"
)
return
async def async_service_failure_hook(
self,
service: ServiceTypes,
duration: float,
error: Union[str, Exception],
error: str | Exception,
call_type: str,
parent_otel_span: Optional[Span] = None,
start_time: Optional[Union[datetime, float]] = None,
end_time: Optional[Union[float, datetime]] = None,
event_metadata: Optional[dict] = None,
parent_otel_span: Span | None = None,
start_time: datetime | float | None = None,
end_time: float | datetime | None = None,
event_metadata: dict | None = None,
):
"""
- For counting if the redis, postgres call is unsuccessful
@ -271,7 +267,7 @@ class ServiceLogging(CustomLogger):
elif isinstance(error, str):
error_message = error
payload = ServiceLoggerPayload(
payload: Final = ServiceLoggerPayload(
is_error=True,
error=error_message,
service=service,
@ -282,7 +278,7 @@ class ServiceLogging(CustomLogger):
# Dedupe OTel loggers per event — see ``async_service_success_hook`` for why
# the same logger can be referenced twice in ``service_callback``.
emitted_otel_logger_ids: set = set()
emitted_otel_logger_ids: Final[set] = set()
for callback in litellm.service_callback:
if callback == "prometheus_system":
await self.init_prometheus_services_logger_if_none()
@ -324,7 +320,7 @@ class ServiceLogging(CustomLogger):
request_data: dict,
original_exception: Exception,
user_api_key_dict: UserAPIKeyAuth,
traceback_str: Optional[str] = None,
traceback_str: str | None = None,
):
"""
Hook to track failed litellm-service calls
@ -347,7 +343,7 @@ class ServiceLogging(CustomLogger):
pass
else:
raise Exception(
"Duration={} is not a float or timedelta object. type={}".format(_duration, type(_duration))
f"Duration={_duration} is not a float or timedelta object. type={type(_duration)}"
) # invalid _duration value
# Batch polling callbacks (check_batch_cost) don't include call_type in kwargs.
# Use .get() to avoid KeyError.

View file

@ -4,7 +4,7 @@ Internal unified UUID helper.
Always uses fastuuid for performance.
"""
import fastuuid as _uuid # type: ignore
import fastuuid as _uuid
# Expose a module-like alias so callers can use: uuid.uuid4()
uuid = _uuid

View file

@ -55,19 +55,15 @@ from litellm.a2a_protocol.main import (
from litellm.types.agents import LiteLLMSendMessageResponse
__all__ = [
# Client
"A2AClient",
# Functions
"asend_message",
"send_message",
"asend_message_streaming",
"aget_agent_card",
"create_a2a_client",
# Response types
"LiteLLMSendMessageResponse",
# Exceptions
"A2AError",
"A2AConnectionError",
"A2AAgentCardError",
"A2AClient",
"A2AConnectionError",
"A2AError",
"A2ALocalhostURLError",
"LiteLLMSendMessageResponse",
"aget_agent_card",
"asend_message",
"asend_message_streaming",
"create_a2a_client",
"send_message",
]

View file

@ -4,7 +4,7 @@ Custom A2A Card Resolver for LiteLLM.
Extends the A2A SDK's card resolver to support multiple well-known paths.
"""
from typing import TYPE_CHECKING, Any, Dict
from typing import TYPE_CHECKING, Any, Final
from litellm._logging import verbose_logger
from litellm.constants import LOCALHOST_URL_PATTERNS
@ -18,8 +18,8 @@ AGENT_CARD_WELL_KNOWN_PATH: str = "/.well-known/agent-card.json"
PREV_AGENT_CARD_WELL_KNOWN_PATH: str = "/.well-known/agent.json"
try:
from a2a.client import A2ACardResolver as _A2ACardResolver # type: ignore[no-redef]
from a2a.utils.constants import ( # type: ignore[no-redef]
from a2a.client import A2ACardResolver as _A2ACardResolver
from a2a.utils.constants import (
AGENT_CARD_WELL_KNOWN_PATH,
PREV_AGENT_CARD_WELL_KNOWN_PATH,
)
@ -43,18 +43,18 @@ def is_localhost_or_internal_url(url: str | None) -> bool:
if not url:
return False
url_lower = url.lower()
url_lower: Final = url.lower()
return any(pattern in url_lower for pattern in LOCALHOST_URL_PATTERNS)
def get_agent_card_url(agent_card: "AgentCard") -> str | None:
"""Return the agent endpoint URL from the resolved SDK card."""
url = getattr(agent_card, "url", None)
url: Final = getattr(agent_card, "url", None)
if url:
return url
interfaces = getattr(agent_card, "supported_interfaces", None)
interfaces: Final = getattr(agent_card, "supported_interfaces", None)
if interfaces:
return getattr(interfaces[0], "url", None)
return None
@ -62,11 +62,11 @@ def get_agent_card_url(agent_card: "AgentCard") -> str | None:
def set_agent_card_url(agent_card: "AgentCard", url: str) -> None:
"""Set the agent endpoint URL on the resolved SDK card."""
normalized = url.rstrip("/") + "/"
normalized: Final = url.rstrip("/") + "/"
if hasattr(agent_card, "url"):
agent_card.url = normalized
interfaces = getattr(agent_card, "supported_interfaces", None)
interfaces: Final = getattr(agent_card, "supported_interfaces", None)
if interfaces:
interfaces[0].url = normalized
@ -86,23 +86,23 @@ def fix_agent_card_url(agent_card: "AgentCard", base_url: str) -> "AgentCard":
Returns:
The agent card with the URL fixed if necessary
"""
card_url = getattr(agent_card, "url", None)
card_url: Final = getattr(agent_card, "url", None)
if card_url and is_localhost_or_internal_url(card_url):
# Normalize base_url to ensure it ends with /
fixed_url = base_url.rstrip("/") + "/"
fixed_url: Final = base_url.rstrip("/") + "/"
agent_card.url = fixed_url
interfaces = getattr(agent_card, "supported_interfaces", None)
interfaces: Final = getattr(agent_card, "supported_interfaces", None)
if interfaces:
interface_url = getattr(interfaces[0], "url", None)
interface_url: Final = getattr(interfaces[0], "url", None)
if interface_url and is_localhost_or_internal_url(interface_url):
interfaces[0].url = base_url.rstrip("/") + "/"
return agent_card
class LiteLLMA2ACardResolver(_A2ACardResolver): # type: ignore[misc]
class LiteLLMA2ACardResolver(_A2ACardResolver):
"""
Custom A2A card resolver that supports multiple well-known paths.
@ -114,7 +114,7 @@ class LiteLLMA2ACardResolver(_A2ACardResolver): # type: ignore[misc]
async def get_agent_card(
self,
relative_card_path: str | None = None,
http_kwargs: Dict[str, Any] | None = None,
http_kwargs: dict[str, Any] | None = None,
) -> "AgentCard":
"""
Fetch the agent card, trying multiple well-known paths.
@ -140,7 +140,7 @@ class LiteLLMA2ACardResolver(_A2ACardResolver): # type: ignore[misc]
)
# Try both well-known paths
paths = [
paths: Final = [
AGENT_CARD_WELL_KNOWN_PATH,
PREV_AGENT_CARD_WELL_KNOWN_PATH,
]
@ -148,13 +148,13 @@ class LiteLLMA2ACardResolver(_A2ACardResolver): # type: ignore[misc]
last_error = None
for path in paths:
try:
verbose_logger.debug(f"Attempting to fetch agent card from {self.base_url}{path}")
verbose_logger.debug("Attempting to fetch agent card from %s%s", self.base_url, path)
return await super().get_agent_card(
relative_card_path=path,
http_kwargs=http_kwargs,
)
except Exception as e:
verbose_logger.debug(f"Failed to fetch agent card from {self.base_url}{path}: {e}")
verbose_logger.debug("Failed to fetch agent card from %s%s: %s", self.base_url, path, e)
last_error = e
continue

View file

@ -4,7 +4,8 @@ LiteLLM A2A Client class.
Provides a class-based interface for A2A agent invocation.
"""
from typing import TYPE_CHECKING, AsyncIterator, Dict, Optional
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Final
from litellm.types.agents import LiteLLMSendMessageResponse
@ -50,7 +51,7 @@ class A2AClient:
self,
base_url: str,
timeout: float = 60.0,
extra_headers: Optional[Dict[str, str]] = None,
extra_headers: dict[str, str] | None = None,
):
"""
Initialize the A2A client wrapper.
@ -63,7 +64,7 @@ class A2AClient:
self.base_url = base_url
self.timeout = timeout
self.extra_headers = extra_headers
self._a2a_client: Optional["A2AClientType"] = None
self._a2a_client: A2AClientType | None = None
async def _get_client(self) -> "A2AClientType":
"""Get or create the underlying A2A client."""
@ -91,7 +92,7 @@ class A2AClient:
"""Send a message to the A2A agent."""
from litellm.a2a_protocol.main import asend_message
a2a_client = await self._get_client()
a2a_client: Final = await self._get_client()
return await asend_message(a2a_client=a2a_client, request=request)
async def send_message_streaming(
@ -100,6 +101,6 @@ class A2AClient:
"""Send a streaming message to the A2A agent."""
from litellm.a2a_protocol.main import asend_message_streaming
a2a_client = await self._get_client()
a2a_client: Final = await self._get_client()
async for chunk in asend_message_streaming(a2a_client=a2a_client, request=request):
yield chunk

View file

@ -5,7 +5,7 @@ Supports dynamic cost parameters that allow platform owners
to define custom costs per agent query or per token.
"""
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any, Final
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import (
@ -18,7 +18,7 @@ else:
class A2ACostCalculator:
@staticmethod
def calculate_a2a_cost(
litellm_logging_obj: Optional[LitellmLoggingObject],
litellm_logging_obj: LitellmLoggingObject | None,
) -> float:
"""
Calculate the cost of an A2A send_message call.
@ -42,23 +42,23 @@ class A2ACostCalculator:
if litellm_logging_obj is None:
return 0.0
model_call_details = litellm_logging_obj.model_call_details
model_call_details: Final = litellm_logging_obj.model_call_details
# Check if user set a custom response cost (backward compatibility)
response_cost = model_call_details.get("response_cost", None)
response_cost: Final = model_call_details.get("response_cost", None)
if response_cost is not None:
return float(response_cost)
# Get litellm_params for cost parameters
litellm_params = model_call_details.get("litellm_params", {}) or {}
litellm_params: Final = model_call_details.get("litellm_params", {}) or {}
# Check for cost_per_query (fixed cost per query)
if litellm_params.get("cost_per_query") is not None:
return float(litellm_params["cost_per_query"])
# Check for token-based pricing
input_cost_per_token = litellm_params.get("input_cost_per_token")
output_cost_per_token = litellm_params.get("output_cost_per_token")
input_cost_per_token: Final = litellm_params.get("input_cost_per_token")
output_cost_per_token: Final = litellm_params.get("output_cost_per_token")
if input_cost_per_token is not None or output_cost_per_token is not None:
return A2ACostCalculator._calculate_token_based_cost(
@ -73,8 +73,8 @@ class A2ACostCalculator:
@staticmethod
def _calculate_token_based_cost(
model_call_details: dict,
input_cost_per_token: Optional[float],
output_cost_per_token: Optional[float],
input_cost_per_token: float | None,
output_cost_per_token: float | None,
) -> float:
"""
Calculate cost based on token usage and per-token pricing.
@ -88,16 +88,16 @@ class A2ACostCalculator:
float: The calculated cost
"""
# Get usage from model_call_details
usage = model_call_details.get("usage")
usage: Final = model_call_details.get("usage")
if usage is None:
return 0.0
# Get token counts
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
prompt_tokens: Final = getattr(usage, "prompt_tokens", 0) or 0
completion_tokens: Final = getattr(usage, "completion_tokens", 0) or 0
# Calculate costs
input_cost = prompt_tokens * (float(input_cost_per_token) if input_cost_per_token else 0.0)
output_cost = completion_tokens * (float(output_cost_per_token) if output_cost_per_token else 0.0)
input_cost: Final = prompt_tokens * (float(input_cost_per_token) if input_cost_per_token else 0.0)
output_cost: Final = completion_tokens * (float(output_cost_per_token) if output_cost_per_token else 0.0)
return input_cost + output_cost

View file

@ -4,7 +4,7 @@ A2A Protocol Exception Mapping Utils.
Maps A2A SDK exceptions to LiteLLM A2A exception types.
"""
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any, Final
from litellm._logging import verbose_logger
from litellm.a2a_protocol.card_resolver import (
@ -29,9 +29,9 @@ try:
A2A_SDK_AVAILABLE = True
except ImportError:
A2A_SDK_AVAILABLE = False
Client = None # type: ignore[misc, assignment]
ClientConfig = None # type: ignore[misc, assignment]
create_client = None # type: ignore[misc, assignment]
Client = None
ClientConfig = None
create_client = None
class A2AExceptionCheckers:
@ -53,11 +53,11 @@ class A2AExceptionCheckers:
if not isinstance(error_str, str):
return False
error_str_lower = error_str.lower()
error_str_lower: Final = error_str.lower()
return any(pattern in error_str_lower for pattern in CONNECTION_ERROR_PATTERNS)
@staticmethod
def is_localhost_url(url: Optional[str]) -> bool:
def is_localhost_url(url: str | None) -> bool:
"""
Check if a URL is a localhost/internal URL.
@ -83,8 +83,8 @@ class A2AExceptionCheckers:
if not isinstance(error_str, str):
return False
error_str_lower = error_str.lower()
agent_card_patterns = [
error_str_lower: Final = error_str.lower()
agent_card_patterns: Final = [
"agent card",
"agent-card",
".well-known",
@ -96,9 +96,9 @@ class A2AExceptionCheckers:
def map_a2a_exception(
original_exception: Exception,
card_url: Optional[str] = None,
api_base: Optional[str] = None,
model: Optional[str] = None,
card_url: str | None = None,
api_base: str | None = None,
model: str | None = None,
) -> Exception:
"""
Map an A2A SDK exception to a LiteLLM A2A exception type.
@ -118,7 +118,7 @@ def map_a2a_exception(
A2AAgentCardError: If the error is related to agent card issues
A2AError: For other A2A-related errors
"""
error_str = str(original_exception)
error_str: Final = str(original_exception)
# Check for localhost URL connection error (special case - retryable)
if (
@ -190,33 +190,38 @@ async def handle_a2a_localhost_retry(
"rewrite, so the upstream URL cannot be corrected."
)
request_type = "streaming " if is_streaming else ""
request_type: Final = "streaming " if is_streaming else ""
verbose_logger.warning(
f"A2A {request_type}request to '{error.localhost_url}' failed: {error.original_error}. "
f"Agent card contains localhost/internal URL. "
f"Retrying with base_url '{error.base_url}'."
"A2A %srequest to '%s' failed: %s. Agent card contains localhost/internal URL. Retrying with base_url '%s'.",
request_type,
error.localhost_url,
error.original_error,
error.base_url,
)
# Fix the agent card URL
set_agent_card_url(agent_card, error.base_url)
# Reuse the httpx client LiteLLM attached at creation. It carries this agent's
# trace-id and auth headers, so a fresh client would drop them. Only clients built
# by ``create_a2a_client`` have it; an externally-supplied client cannot be retried.
httpx_client = getattr(a2a_client, "_litellm_httpx_client", None)
# Reuse the httpx client and call context LiteLLM attached at creation, since the
# context carries this agent's trace-id/auth headers. Only clients built by
# ``create_a2a_client`` have them; an externally-supplied client cannot be retried.
httpx_client: Final = getattr(a2a_client, "_litellm_httpx_client", None)
if httpx_client is None:
raise RuntimeError(
"Cannot retry A2A localhost URL fix: the client was not created by "
"create_a2a_client, so no LiteLLM httpx client is attached."
)
new_client = await create_client( # pyright: ignore[reportOptionalCall]
new_client: Final = await create_client( # pyright: ignore[reportOptionalCall]
agent_card,
client_config=ClientConfig( # pyright: ignore[reportOptionalCall]
httpx_client=httpx_client,
streaming=is_streaming,
),
)
new_client._litellm_httpx_client = httpx_client # type: ignore[attr-defined]
new_client._litellm_agent_card = agent_card # type: ignore[attr-defined]
new_client._litellm_httpx_client = httpx_client
new_client._litellm_call_context = getattr( # pyright: ignore[reportAttributeAccessIssue] # LiteLLM-owned stash
a2a_client, "_litellm_call_context", None
)
new_client._litellm_agent_card = agent_card
return new_client

View file

@ -4,8 +4,6 @@ A2A Protocol Exceptions.
Custom exception types for A2A protocol operations, following LiteLLM's exception pattern.
"""
from typing import Optional
import httpx
@ -21,11 +19,11 @@ class A2AError(Exception):
message: str,
status_code: int = 500,
llm_provider: str = "a2a_agent",
model: Optional[str] = None,
response: Optional[httpx.Response] = None,
litellm_debug_info: Optional[str] = None,
max_retries: Optional[int] = None,
num_retries: Optional[int] = None,
model: str | None = None,
response: httpx.Response | None = None,
litellm_debug_info: str | None = None,
max_retries: int | None = None,
num_retries: int | None = None,
):
self.status_code = status_code
self.message = f"litellm.A2AError: {message}"
@ -65,12 +63,12 @@ class A2AConnectionError(A2AError):
def __init__(
self,
message: str,
url: Optional[str] = None,
model: Optional[str] = None,
response: Optional[httpx.Response] = None,
litellm_debug_info: Optional[str] = None,
max_retries: Optional[int] = None,
num_retries: Optional[int] = None,
url: str | None = None,
model: str | None = None,
response: httpx.Response | None = None,
litellm_debug_info: str | None = None,
max_retries: int | None = None,
num_retries: int | None = None,
):
self.url = url
super().__init__(
@ -98,10 +96,10 @@ class A2AAgentCardError(A2AError):
def __init__(
self,
message: str,
url: Optional[str] = None,
model: Optional[str] = None,
response: Optional[httpx.Response] = None,
litellm_debug_info: Optional[str] = None,
url: str | None = None,
model: str | None = None,
response: httpx.Response | None = None,
litellm_debug_info: str | None = None,
):
self.url = url
super().__init__(
@ -132,8 +130,8 @@ class A2ALocalhostURLError(A2AConnectionError):
self,
localhost_url: str,
base_url: str,
original_error: Optional[Exception] = None,
model: Optional[str] = None,
original_error: Exception | None = None,
model: str | None = None,
):
self.localhost_url = localhost_url
self.base_url = base_url

View file

@ -16,8 +16,8 @@ from litellm.a2a_protocol.litellm_completion_bridge.transformation import (
)
__all__ = [
"A2ACompletionBridgeTransformation",
"A2ACompletionBridgeHandler",
"A2ACompletionBridgeTransformation",
"handle_a2a_completion",
"handle_a2a_completion_streaming",
]

View file

@ -10,7 +10,8 @@ A2A Streaming Events (in order):
4. Status update (kind: "status-update") - Final status "completed" with final=true
"""
from typing import Any, AsyncIterator, Dict, Optional
from collections.abc import AsyncIterator, Mapping
from typing import Any, Final
import litellm
from litellm._logging import verbose_logger
@ -20,14 +21,16 @@ from litellm.a2a_protocol.litellm_completion_bridge.transformation import (
)
from litellm.a2a_protocol.providers.config_manager import A2AProviderConfigManager
from litellm.interactions.agents.utils import merge_agent_headers
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.types.utils import ModelResponse
# litellm_params key carrying the authenticated principal (hashed virtual key) so
# A2A provider configs can scope provider-side state (e.g. LangFlow session memory)
# per key instead of trusting the client-supplied A2A contextId.
A2A_USER_API_KEY_HASH_PARAM = "litellm_a2a_user_api_key_hash"
A2A_USER_API_KEY_HASH_PARAM: Final = "litellm_a2a_user_api_key_hash"
# Agent metadata fields stored in litellm_params that are not valid litellm.acompletion() kwargs
_AGENT_ONLY_PARAMS = frozenset(
_AGENT_ONLY_PARAMS: Final = frozenset(
{
"is_public",
"agent_name",
@ -44,56 +47,23 @@ class A2ACompletionBridgeHandler:
"""
@staticmethod
async def handle_non_streaming(
request_id: str,
params: Dict[str, Any],
litellm_params: Dict[str, Any],
api_base: Optional[str] = None,
agent_extra_headers: Optional[Dict[str, str]] = None,
def _build_completion_params(
params: dict[str, Any],
litellm_params: Mapping[str, Any],
api_base: str | None,
agent_extra_headers: Mapping[str, str] | None,
*,
_skip_a2a_provider_routing: bool = False,
) -> Dict[str, Any]:
"""
Handle non-streaming A2A request via litellm.acompletion.
Args:
request_id: A2A JSON-RPC request ID
params: A2A MessageSendParams containing the message
litellm_params: Agent's litellm_params (custom_llm_provider, model, etc.)
api_base: API base URL from agent_card_params
agent_extra_headers: Per-request headers (from x-a2a-{agent}-* rewrite and
admin extra_headers) to forward on the upstream HTTP call.
Returns:
A2A SendMessageResponse dict
"""
custom_llm_provider = litellm_params.get("custom_llm_provider")
if not _skip_a2a_provider_routing:
a2a_provider_config = A2AProviderConfigManager.get_provider_config(
custom_llm_provider=custom_llm_provider,
model=litellm_params.get("model"),
)
if a2a_provider_config is not None:
verbose_logger.info(f"A2A: Using provider config for {custom_llm_provider}")
return await a2a_provider_config.handle_non_streaming(
request_id=request_id,
params=params,
api_base=api_base,
litellm_params=litellm_params,
agent_extra_headers=agent_extra_headers,
)
stream: bool,
) -> Mapping[str, Any]:
# Extract message from params
message = params.get("message", {})
message: Final = params.get("message", {})
# Transform A2A message to OpenAI format
openai_messages = A2ACompletionBridgeTransformation.a2a_message_to_openai_messages(message)
openai_messages: Final = A2ACompletionBridgeTransformation.a2a_message_to_openai_messages(message)
# Get completion params
custom_llm_provider = litellm_params.get("custom_llm_provider")
model = litellm_params.get("model", "agent")
custom_llm_provider: Final = litellm_params.get("custom_llm_provider")
model: Final = litellm_params.get("model", "agent")
# Build full model string if provider specified
# Skip prepending if model already starts with the provider prefix
@ -102,17 +72,20 @@ class A2ACompletionBridgeHandler:
else:
full_model = model
verbose_logger.info(f"A2A completion bridge: model={full_model}, api_base={api_base}")
if stream:
verbose_logger.info("A2A completion bridge streaming: model=%s, api_base=%s", full_model, api_base)
else:
verbose_logger.info("A2A completion bridge: model=%s, api_base=%s", full_model, api_base)
# Build completion params dict
completion_params: Dict[str, Any] = {
completion_params: Final[dict[str, Any]] = {
"model": full_model,
"messages": openai_messages,
"api_base": api_base,
"stream": False,
"stream": stream,
}
# Add litellm_params (contains api_key, client_id, client_secret, tenant_id, etc.)
litellm_params_to_add = {
litellm_params_to_add: Final = {
k: v
for k, v in litellm_params.items()
if k not in ("model", "custom_llm_provider") and k not in _AGENT_ONLY_PARAMS
@ -133,29 +106,85 @@ class A2ACompletionBridgeHandler:
static_headers=completion_params.get("extra_headers"),
)
return completion_params
@staticmethod
async def _acompletion(completion_params: Mapping[str, Any]) -> ModelResponse | CustomStreamWrapper:
return await litellm.acompletion(**completion_params)
@staticmethod
async def handle_non_streaming(
request_id: str,
params: dict[str, Any],
litellm_params: dict[str, Any],
api_base: str | None = None,
agent_extra_headers: dict[str, str] | None = None,
*,
_skip_a2a_provider_routing: bool = False,
) -> dict[str, object]:
"""
Handle non-streaming A2A request via litellm.acompletion.
Args:
request_id: A2A JSON-RPC request ID
params: A2A MessageSendParams containing the message
litellm_params: Agent's litellm_params (custom_llm_provider, model, etc.)
api_base: API base URL from agent_card_params
agent_extra_headers: Per-request headers (from x-a2a-{agent}-* rewrite and
admin extra_headers) to forward on the upstream HTTP call.
Returns:
A2A SendMessageResponse dict
"""
custom_llm_provider: Final = litellm_params.get("custom_llm_provider")
if not _skip_a2a_provider_routing:
a2a_provider_config: Final = A2AProviderConfigManager.get_provider_config(
custom_llm_provider=custom_llm_provider,
model=litellm_params.get("model"),
)
if a2a_provider_config is not None:
verbose_logger.info("A2A: Using provider config for %s", custom_llm_provider)
return await a2a_provider_config.handle_non_streaming(
request_id=request_id,
params=params,
api_base=api_base,
litellm_params=litellm_params,
agent_extra_headers=agent_extra_headers,
)
completion_params: Final = A2ACompletionBridgeHandler._build_completion_params(
params=params,
litellm_params=litellm_params,
api_base=api_base,
agent_extra_headers=agent_extra_headers,
stream=False,
)
# Call litellm.acompletion
response = await litellm.acompletion(**completion_params)
response: Final = await A2ACompletionBridgeHandler._acompletion(completion_params)
# Transform response to A2A format
a2a_response = A2ACompletionBridgeTransformation.openai_response_to_a2a_response(
a2a_response: Final = A2ACompletionBridgeTransformation.openai_response_to_a2a_response(
response=response,
request_id=request_id,
)
verbose_logger.info(f"A2A completion bridge completed: request_id={request_id}")
verbose_logger.info("A2A completion bridge completed: request_id=%s", request_id)
return a2a_response
@staticmethod
async def handle_streaming(
request_id: str,
params: Dict[str, Any],
litellm_params: Dict[str, Any],
api_base: Optional[str] = None,
agent_extra_headers: Optional[Dict[str, str]] = None,
params: dict[str, Any],
litellm_params: dict[str, Any],
api_base: str | None = None,
agent_extra_headers: dict[str, str] | None = None,
*,
_skip_a2a_provider_routing: bool = False,
) -> AsyncIterator[Dict[str, Any]]:
) -> AsyncIterator[dict[str, object]]:
"""
Handle streaming A2A request via litellm.acompletion with stream=True.
@ -176,15 +205,15 @@ class A2ACompletionBridgeHandler:
Yields:
A2A streaming response events
"""
custom_llm_provider = litellm_params.get("custom_llm_provider")
custom_llm_provider: Final = litellm_params.get("custom_llm_provider")
if not _skip_a2a_provider_routing:
a2a_provider_config = A2AProviderConfigManager.get_provider_config(
a2a_provider_config: Final = A2AProviderConfigManager.get_provider_config(
custom_llm_provider=custom_llm_provider,
model=litellm_params.get("model"),
)
if a2a_provider_config is not None:
verbose_logger.info(f"A2A: Using provider config for {custom_llm_provider} (streaming)")
verbose_logger.info("A2A: Using provider config for %s (streaming)", custom_llm_provider)
async for chunk in a2a_provider_config.handle_streaming(
request_id=request_id,
@ -197,66 +226,26 @@ class A2ACompletionBridgeHandler:
return
# Extract message from params
message = params.get("message", {})
# Create streaming context
ctx = A2AStreamingContext(
ctx: Final = A2AStreamingContext(
request_id=request_id,
input_message=message,
input_message=params.get("message", {}),
)
# Transform A2A message to OpenAI format
openai_messages = A2ACompletionBridgeTransformation.a2a_message_to_openai_messages(message)
# Get completion params
custom_llm_provider = litellm_params.get("custom_llm_provider")
model = litellm_params.get("model", "agent")
# Build full model string if provider specified
# Skip prepending if model already starts with the provider prefix
if custom_llm_provider and not model.startswith(f"{custom_llm_provider}/"):
full_model = f"{custom_llm_provider}/{model}"
else:
full_model = model
verbose_logger.info(f"A2A completion bridge streaming: model={full_model}, api_base={api_base}")
# Build completion params dict
completion_params: Dict[str, Any] = {
"model": full_model,
"messages": openai_messages,
"api_base": api_base,
"stream": True,
}
# Add litellm_params (contains api_key, client_id, client_secret, tenant_id, etc.)
litellm_params_to_add = {
k: v
for k, v in litellm_params.items()
if k not in ("model", "custom_llm_provider") and k not in _AGENT_ONLY_PARAMS
}
completion_params.update(litellm_params_to_add)
# Apply forward metadata AFTER the litellm_params merge so the helper
# sees any agent-owner-configured ``extra_body.metadata`` and can keep
# those keys authoritative over the client-supplied A2A metadata.
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
completion_params=completion_params,
a2a_message=message,
completion_params: Final = A2ACompletionBridgeHandler._build_completion_params(
params=params,
litellm_params=litellm_params,
api_base=api_base,
agent_extra_headers=agent_extra_headers,
stream=True,
)
if agent_extra_headers:
completion_params["extra_headers"] = merge_agent_headers(
dynamic_headers=agent_extra_headers,
static_headers=completion_params.get("extra_headers"),
)
# 1. Emit initial task event (kind: "task", status: "submitted")
task_event = A2ACompletionBridgeTransformation.create_task_event(ctx)
task_event: Final = A2ACompletionBridgeTransformation.create_task_event(ctx)
yield task_event
# 2. Emit status update (kind: "status-update", status: "working")
working_event = A2ACompletionBridgeTransformation.create_status_update_event(
working_event: Final = A2ACompletionBridgeTransformation.create_status_update_event(
ctx=ctx,
state="working",
final=False,
@ -265,12 +254,12 @@ class A2ACompletionBridgeHandler:
yield working_event
# Call litellm.acompletion with streaming
response = await litellm.acompletion(**completion_params)
response: Final = await A2ACompletionBridgeHandler._acompletion(completion_params)
# 3. Accumulate content and emit artifact update
accumulated_text = ""
chunk_count = 0
async for chunk in response: # type: ignore[union-attr]
async for chunk in response:
chunk_count += 1
# Extract delta content
@ -285,31 +274,33 @@ class A2ACompletionBridgeHandler:
# Emit artifact update with accumulated content
if accumulated_text:
artifact_event = A2ACompletionBridgeTransformation.create_artifact_update_event(
artifact_event: Final = A2ACompletionBridgeTransformation.create_artifact_update_event(
ctx=ctx,
text=accumulated_text,
)
yield artifact_event
# 4. Emit final status update (kind: "status-update", status: "completed", final: true)
completed_event = A2ACompletionBridgeTransformation.create_status_update_event(
completed_event: Final = A2ACompletionBridgeTransformation.create_status_update_event(
ctx=ctx,
state="completed",
final=True,
)
yield completed_event
verbose_logger.info(f"A2A completion bridge streaming completed: request_id={request_id}, chunks={chunk_count}")
verbose_logger.info(
"A2A completion bridge streaming completed: request_id=%s, chunks=%s", request_id, chunk_count
)
# Convenience functions that delegate to the class methods
async def handle_a2a_completion(
request_id: str,
params: Dict[str, Any],
litellm_params: Dict[str, Any],
api_base: Optional[str] = None,
agent_extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
params: dict[str, Any],
litellm_params: dict[str, Any],
api_base: str | None = None,
agent_extra_headers: dict[str, str] | None = None,
) -> dict[str, object]:
"""Convenience function for non-streaming A2A completion."""
return await A2ACompletionBridgeHandler.handle_non_streaming(
request_id=request_id,
@ -322,11 +313,11 @@ async def handle_a2a_completion(
async def handle_a2a_completion_streaming(
request_id: str,
params: Dict[str, Any],
litellm_params: Dict[str, Any],
api_base: Optional[str] = None,
agent_extra_headers: Optional[Dict[str, str]] = None,
) -> AsyncIterator[Dict[str, Any]]:
params: dict[str, Any],
litellm_params: dict[str, Any],
api_base: str | None = None,
agent_extra_headers: dict[str, str] | None = None,
) -> AsyncIterator[dict[str, object]]:
"""Convenience function for streaming A2A completion."""
async for chunk in A2ACompletionBridgeHandler.handle_streaming(
request_id=request_id,

View file

@ -18,7 +18,7 @@ A2A Streaming Events:
"""
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from typing import Any, Final
from uuid import uuid4
from litellm._logging import verbose_logger
@ -30,7 +30,7 @@ class A2AStreamingContext:
Tracks task_id, context_id, and message accumulation.
"""
def __init__(self, request_id: str, input_message: Dict[str, Any]):
def __init__(self, request_id: str, input_message: dict[str, Any]):
self.request_id = request_id
self.task_id = str(uuid4())
self.context_id = str(uuid4())
@ -46,9 +46,9 @@ class A2ACompletionBridgeTransformation:
"""
@staticmethod
def _extract_text_from_a2a_parts(parts: List[Dict[str, Any]]) -> str:
def _extract_text_from_a2a_parts(parts: list[dict[str, Any]]) -> str:
"""Extract text from A2A parts (with or without explicit ``kind``)."""
content_parts: List[str] = []
content_parts: Final[list[str]] = []
for part in parts:
if not isinstance(part, dict):
continue
@ -62,35 +62,35 @@ class A2ACompletionBridgeTransformation:
@staticmethod
def get_forward_metadata(
a2a_message: Dict[str, Any],
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
a2a_message: dict[str, Any],
params: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""
Merge A2A metadata from MessageSendParams and the message for downstream providers.
Forwarded once on the LangGraph run payload (``metadata``), not duplicated on
each input message see ``apply_forward_metadata_to_completion_params``.
"""
merged: Dict[str, Any] = {}
merged: Final[dict[str, Any]] = {}
if params and isinstance(params.get("metadata"), dict):
merged.update(params["metadata"])
message_metadata = a2a_message.get("metadata")
message_metadata: Final = a2a_message.get("metadata")
if isinstance(message_metadata, dict):
merged.update(message_metadata)
return merged or None
@staticmethod
def apply_forward_metadata_to_completion_params(
completion_params: Dict[str, Any],
a2a_message: Dict[str, Any],
params: Optional[Dict[str, Any]] = None,
completion_params: dict[str, Any],
a2a_message: dict[str, Any],
params: dict[str, Any] | None = None,
) -> None:
"""
Attach A2A metadata to completion kwargs for provider bridges (e.g. LangGraph).
Uses ``extra_body`` so we do not collide with LiteLLM's spend-log ``metadata`` kwarg.
"""
forward_metadata = A2ACompletionBridgeTransformation.get_forward_metadata(
forward_metadata: Final = A2ACompletionBridgeTransformation.get_forward_metadata(
a2a_message=a2a_message,
params=params,
)
@ -103,18 +103,18 @@ class A2ACompletionBridgeTransformation:
# Layer client-supplied A2A metadata under any agent-owner-configured
# ``extra_body.metadata`` so the configured keys remain authoritative
# and an A2A caller cannot overwrite server-set run metadata.
existing_metadata = extra_body.get("metadata")
existing_dict: Dict[str, Any] = existing_metadata if isinstance(existing_metadata, dict) else {}
merged_metadata: Dict[str, Any] = {**forward_metadata, **existing_dict}
existing_metadata: Final = extra_body.get("metadata")
existing_dict: Final[dict[str, Any]] = existing_metadata if isinstance(existing_metadata, dict) else {}
merged_metadata: Final[dict[str, Any]] = {**forward_metadata, **existing_dict}
extra_body = {**extra_body, "metadata": merged_metadata}
completion_params["extra_body"] = extra_body
verbose_logger.debug(f"A2A -> completion forward metadata keys={list(forward_metadata.keys())}")
verbose_logger.debug("A2A -> completion forward metadata keys=%s", list(forward_metadata.keys()))
@staticmethod
def a2a_message_to_openai_messages(
a2a_message: Dict[str, Any],
) -> List[Dict[str, Any]]:
a2a_message: dict[str, Any],
) -> list[dict[str, Any]]:
"""
Transform an A2A message to OpenAI message format.
@ -124,7 +124,7 @@ class A2ACompletionBridgeTransformation:
Returns:
List of OpenAI-format messages
"""
role = a2a_message.get("role", "user")
role: Final = a2a_message.get("role", "user")
parts = a2a_message.get("parts", [])
# Map A2A roles to OpenAI roles
@ -139,21 +139,23 @@ class A2ACompletionBridgeTransformation:
if not isinstance(parts, list):
parts = []
content = A2ACompletionBridgeTransformation._extract_text_from_a2a_parts(parts)
content: Final = A2ACompletionBridgeTransformation._extract_text_from_a2a_parts(parts)
# Do not attach A2A message.metadata here — the completion bridge forwards it
# once at run level via extra_body.metadata (LangGraph POST /runs/wait shape).
openai_message: Dict[str, Any] = {"role": openai_role, "content": content}
openai_message: Final[dict[str, Any]] = {"role": openai_role, "content": content}
verbose_logger.debug(f"A2A -> OpenAI transform: role={role} -> {openai_role}, content_length={len(content)}")
verbose_logger.debug(
"A2A -> OpenAI transform: role=%s -> %s, content_length=%s", role, openai_role, len(content)
)
return [openai_message]
@staticmethod
def openai_response_to_a2a_response(
response: Any,
request_id: Optional[str] = None,
) -> Dict[str, Any]:
request_id: str | None = None,
) -> dict[str, Any]:
"""
Transform a LiteLLM ModelResponse to A2A SendMessageResponse format.
@ -167,12 +169,12 @@ class A2ACompletionBridgeTransformation:
# Extract content from response
content = ""
if hasattr(response, "choices") and response.choices:
choice = response.choices[0]
choice: Final = response.choices[0]
if hasattr(choice, "message") and choice.message:
content = choice.message.content or ""
# Build A2A message
a2a_message = {
a2a_message: Final = {
"kind": "message",
"role": "agent",
"parts": [{"kind": "text", "text": content}],
@ -180,13 +182,13 @@ class A2ACompletionBridgeTransformation:
}
# Build A2A response
a2a_response = {
a2a_response: Final = {
"jsonrpc": "2.0",
"id": request_id,
"result": a2a_message,
}
verbose_logger.debug(f"OpenAI -> A2A transform: content_length={len(content)}")
verbose_logger.debug("OpenAI -> A2A transform: content_length=%s", len(content))
return a2a_response
@ -198,7 +200,7 @@ class A2ACompletionBridgeTransformation:
@staticmethod
def create_task_event(
ctx: A2AStreamingContext,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Create the initial task event with status 'submitted'.
@ -232,8 +234,8 @@ class A2ACompletionBridgeTransformation:
ctx: A2AStreamingContext,
state: str,
final: bool = False,
message_text: Optional[str] = None,
) -> Dict[str, Any]:
message_text: str | None = None,
) -> dict[str, Any]:
"""
Create a status update event.
@ -243,7 +245,7 @@ class A2ACompletionBridgeTransformation:
final: Whether this is the final event
message_text: Optional message text for 'working' status
"""
status: Dict[str, Any] = {
status: Final[dict[str, Any]] = {
"state": state,
"timestamp": A2ACompletionBridgeTransformation._get_timestamp(),
}
@ -275,7 +277,7 @@ class A2ACompletionBridgeTransformation:
def create_artifact_update_event(
ctx: A2AStreamingContext,
text: str,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Create an artifact update event with content.

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