diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index cbf380bac01..fc86c229c81 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,4 +1,4 @@
-blank_issues_enabled: true
+blank_issues_enabled: false
contact_links:
- name: Schedule Demo
url: https://enterprise.litellm.ai/demo
diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml
index 9f1283da19e..06d369eabcd 100644
--- a/.github/workflows/test-linting.yml
+++ b/.github/workflows/test-linting.yml
@@ -113,7 +113,7 @@ jobs:
- name: Check ruff format
if: steps.changes.outputs.decision != 'skip'
run: |
- git diff --name-only --diff-filter=ACMR "$GATE_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 -- ':(glob)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
@@ -172,7 +172,7 @@ jobs:
- name: Check tests/e2e basedpyright (zero errors)
if: steps.changes.outputs.decision != 'skip'
run: |
- if git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- 'tests/e2e/**/*.py' | grep -q .; then
+ if git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- ':(glob)tests/e2e/**/*.py' | grep -q .; then
uv run --no-sync basedpyright tests/e2e
else
echo "No changed tests/e2e Python files; skipping."
diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml
index c6901411167..17b6481a2bf 100644
--- a/.github/workflows/test-rust.yml
+++ b/.github/workflows/test-rust.yml
@@ -4,8 +4,22 @@ on:
push:
paths:
- "litellm-rust/**"
+ - "litellm/rust_bridge/**"
+ - "tests/test_litellm_rust/**"
+ - "litellm/integrations/custom_logger.py"
+ - "litellm/litellm_core_utils/litellm_logging.py"
+ - "litellm/litellm_core_utils/logging_worker.py"
+ - "litellm/proxy/guardrails/**"
+ - "litellm/utils.py"
+ - "litellm/ocr/**"
+ - "litellm/llms/base_llm/ocr/**"
+ - "litellm/llms/custom_httpx/llm_http_handler.py"
+ - "tests/test_litellm/ocr/**"
+ - "tests/test_litellm/conftest.py"
+ - "Makefile"
- ".cargo/**"
- "pyproject.toml"
+ - "uv.lock"
- "rust-toolchain.toml"
- ".github/actions/setup-uv-with-retries/**"
- ".github/scripts/smoke_test_native_wheel.py"
@@ -20,8 +34,22 @@ on:
- "litellm_**"
paths:
- "litellm-rust/**"
+ - "litellm/rust_bridge/**"
+ - "tests/test_litellm_rust/**"
+ - "litellm/integrations/custom_logger.py"
+ - "litellm/litellm_core_utils/litellm_logging.py"
+ - "litellm/litellm_core_utils/logging_worker.py"
+ - "litellm/proxy/guardrails/**"
+ - "litellm/utils.py"
+ - "litellm/ocr/**"
+ - "litellm/llms/base_llm/ocr/**"
+ - "litellm/llms/custom_httpx/llm_http_handler.py"
+ - "tests/test_litellm/ocr/**"
+ - "tests/test_litellm/conftest.py"
+ - "Makefile"
- ".cargo/**"
- "pyproject.toml"
+ - "uv.lock"
- "rust-toolchain.toml"
- ".github/actions/setup-uv-with-retries/**"
- ".github/scripts/smoke_test_native_wheel.py"
diff --git a/.github/workflows/test-terraform-modules.yml b/.github/workflows/test-terraform-modules.yml
index 52006d9b578..e6896604b7f 100644
--- a/.github/workflows/test-terraform-modules.yml
+++ b/.github/workflows/test-terraform-modules.yml
@@ -25,13 +25,17 @@ concurrency:
cancel-in-progress: true
jobs:
- aws-module:
- name: fmt, validate, test (aws)
+ module:
+ name: fmt, validate, test (${{ matrix.module }})
runs-on: ubuntu-latest
timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ module: [aws, gcp]
defaults:
run:
- working-directory: terraform/litellm/aws
+ working-directory: terraform/litellm/${{ matrix.module }}
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
@@ -51,35 +55,7 @@ jobs:
- name: validate
run: terraform validate
- # Plan-only, mock_provider-backed: no AWS credentials, no API calls.
+ # Plan-only, mock_provider-backed: no cloud credentials, no API calls.
- name: test
run: terraform test
- gcp-module:
- name: fmt, validate, test (gcp)
- runs-on: ubuntu-latest
- timeout-minutes: 15
- defaults:
- run:
- working-directory: terraform/litellm/gcp
- steps:
- - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- with:
- persist-credentials: false
-
- - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
- with:
- terraform_version: 1.13.3
- terraform_wrapper: false
-
- - name: fmt
- run: terraform fmt -recursive -check -diff
-
- - name: init
- run: terraform init -backend=false -input=false
-
- - name: validate
- run: terraform validate
-
- - name: test
- run: terraform test
diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml
index cc606339a20..f55c87c2ae5 100644
--- a/.github/workflows/test-unit.yml
+++ b/.github/workflows/test-unit.yml
@@ -200,6 +200,7 @@ jobs:
tests/test_litellm/proxy/types_utils
tests/test_litellm/proxy/logging_endpoints
tests/test_litellm/proxy/test_*.py
+ tests/test_gateway
workers: 4
reruns: 2
timeout-minutes: 20
diff --git a/AGENTS.md b/AGENTS.md
index 41921fdff4d..a1e8f6f618d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1 +1,3 @@
Read @CLAUDE.md for coding guidelines
+
+Before requesting maintainer review, verify the current PR tip passes required CI and code coverage, meets Greptile confidence of at least 4/5, and has acceptable Veria and Bugbot reviews. Inspect warnings and findings, fix actionable issues, and rerun the affected checks and reviewers after changes. Record evidence for any false positive or unavailable review; never treat a pending or missing bot result as a pass. Do not lower coverage thresholds or lint budgets to satisfy a check
diff --git a/Dockerfile b/Dockerfile
index 0a92aa9a68c..759dac76795 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,9 +8,25 @@ ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:e624c5d5e42382ce7
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:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
+# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x)
+ARG PGBOUNCER_VERSION=1.25.2
+ARG PGBOUNCER_SHA256=924ad35113fd0a71c8e2dbe85b5d03445532e2b7b37a9f8a48983beea238b332
FROM $UV_IMAGE AS uvbin
+FROM $LITELLM_BUILD_IMAGE AS pgbouncer-builder
+ARG PGBOUNCER_VERSION
+ARG PGBOUNCER_SHA256
+USER root
+RUN apk add --no-cache build-base pkgconf libevent-dev openssl-dev curl
+WORKDIR /build
+RUN curl -fsSL -o pgbouncer.tar.gz "https://www.pgbouncer.org/downloads/files/${PGBOUNCER_VERSION}/pgbouncer-${PGBOUNCER_VERSION}.tar.gz" && \
+ echo "${PGBOUNCER_SHA256} pgbouncer.tar.gz" | sha256sum -c - && \
+ tar xzf pgbouncer.tar.gz --strip-components=1 && \
+ ./configure --prefix=/usr/local --with-openssl=/usr && \
+ make -j"$(nproc)" pgbouncer && \
+ install -m 0755 pgbouncer /usr/local/bin/pgbouncer
+
# Admin UI builder. Pinned to the build platform so the architecture-independent
# Next.js static export compiles once natively even in a multi-arch build,
# instead of once per target arch under QEMU.
@@ -110,7 +126,8 @@ USER root
RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories
# node (without npm) is required by the prisma CLI at runtime
-RUN apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile
+RUN apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile libevent
+COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer
WORKDIR /app
ENV PATH="/app/.venv/bin:${PATH}" \
diff --git a/Makefile b/Makefile
index 91835e19e3c..d360074ea4e 100644
--- a/Makefile
+++ b/Makefile
@@ -150,8 +150,8 @@ lint-install:
# Diff-scoped format check, mirroring test-linting.yml's "Check ruff format" step:
# only the litellm Python files changed vs the base are checked, so a pre-existing
# format issue elsewhere doesn't block an unrelated commit. Git pathspecs match
-# recursively, so 'litellm/*.py' covers nested modules and the top-level files that
-# CI's 'litellm/**/*.py' skips, which makes this target a superset of the CI step.
+# recursively, so 'litellm/*.py' covers top-level files and nested modules alike,
+# the same set CI's ':(glob)litellm/**/*.py' selects.
lint-format-check-changed: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
@base_ref=$$($(RESOLVE_BASE)) && \
changed=$$(git diff --name-only --diff-filter=ACMR "$$base_ref...HEAD" -- 'litellm/*.py') && \
diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database
index e9ad2849bb2..b0bf935c616 100644
--- a/docker/Dockerfile.database
+++ b/docker/Dockerfile.database
@@ -8,9 +8,25 @@ ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:e624c5d5e42382ce7
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:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
+# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x)
+ARG PGBOUNCER_VERSION=1.25.2
+ARG PGBOUNCER_SHA256=924ad35113fd0a71c8e2dbe85b5d03445532e2b7b37a9f8a48983beea238b332
FROM $UV_IMAGE AS uvbin
+FROM $LITELLM_BUILD_IMAGE AS pgbouncer-builder
+ARG PGBOUNCER_VERSION
+ARG PGBOUNCER_SHA256
+USER root
+RUN apk add --no-cache build-base pkgconf libevent-dev openssl-dev curl
+WORKDIR /build
+RUN curl -fsSL -o pgbouncer.tar.gz "https://www.pgbouncer.org/downloads/files/${PGBOUNCER_VERSION}/pgbouncer-${PGBOUNCER_VERSION}.tar.gz" && \
+ echo "${PGBOUNCER_SHA256} pgbouncer.tar.gz" | sha256sum -c - && \
+ tar xzf pgbouncer.tar.gz --strip-components=1 && \
+ ./configure --prefix=/usr/local --with-openssl=/usr && \
+ make -j"$(nproc)" pgbouncer && \
+ install -m 0755 pgbouncer /usr/local/bin/pgbouncer
+
# Admin UI builder. Pinned to the build platform so the architecture-independent
# Next.js static export compiles once natively even in a multi-arch build,
# instead of once per target arch under QEMU.
@@ -101,7 +117,8 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime
USER root
# node (without npm) is required by the prisma CLI at runtime
-RUN apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile
+RUN apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile libevent
+COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer
WORKDIR /app
ENV PATH="/app/.venv/bin:${PATH}" \
diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root
index edf20e8bbff..5d729046678 100644
--- a/docker/Dockerfile.non_root
+++ b/docker/Dockerfile.non_root
@@ -7,9 +7,25 @@ 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:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
+# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x)
+ARG PGBOUNCER_VERSION=1.25.2
+ARG PGBOUNCER_SHA256=924ad35113fd0a71c8e2dbe85b5d03445532e2b7b37a9f8a48983beea238b332
FROM $UV_IMAGE AS uvbin
+FROM $LITELLM_BUILD_IMAGE AS pgbouncer-builder
+ARG PGBOUNCER_VERSION
+ARG PGBOUNCER_SHA256
+USER root
+RUN apk add --no-cache build-base pkgconf libevent-dev openssl-dev curl
+WORKDIR /build
+RUN curl -fsSL -o pgbouncer.tar.gz "https://www.pgbouncer.org/downloads/files/${PGBOUNCER_VERSION}/pgbouncer-${PGBOUNCER_VERSION}.tar.gz" && \
+ echo "${PGBOUNCER_SHA256} pgbouncer.tar.gz" | sha256sum -c - && \
+ tar xzf pgbouncer.tar.gz --strip-components=1 && \
+ ./configure --prefix=/usr/local --with-openssl=/usr && \
+ make -j"$(nproc)" pgbouncer && \
+ install -m 0755 pgbouncer /usr/local/bin/pgbouncer
+
# Admin UI builder. Pinned to the build platform so the architecture-independent
# Next.js static export compiles once natively even in a multi-arch build,
# instead of once per target arch under QEMU.
@@ -128,8 +144,9 @@ RUN for i in 1 2 3; do \
apk upgrade --no-cache && break || sleep 5; \
done && \
for i in 1 2 3; do \
- apk add --no-cache python-3.13 bash openssl tzdata libsndfile nodejs && break || sleep 5; \
+ apk add --no-cache python-3.13 bash openssl tzdata libsndfile nodejs libevent && break || sleep 5; \
done
+COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer
# Copy only what runtime needs. The application is installed inside the venv;
# the rest of the builder's /app is source and build metadata that must not
diff --git a/gateway/Dockerfile b/gateway/Dockerfile
index 308d70a6b26..33d3791dbba 100644
--- a/gateway/Dockerfile
+++ b/gateway/Dockerfile
@@ -1,9 +1,25 @@
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:e624c5d5e42382ce7165ddafcbbf8e6769a24cbd02ea6114b880b05ae5ba2a8d
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:e624c5d5e42382ce7165ddafcbbf8e6769a24cbd02ea6114b880b05ae5ba2a8d
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
+# Checksum from https://www.pgbouncer.org/downloads/ (the Wolfi repo only carries 1.24.x)
+ARG PGBOUNCER_VERSION=1.25.2
+ARG PGBOUNCER_SHA256=924ad35113fd0a71c8e2dbe85b5d03445532e2b7b37a9f8a48983beea238b332
FROM $UV_IMAGE AS uvbin
+FROM $LITELLM_BUILD_IMAGE AS pgbouncer-builder
+ARG PGBOUNCER_VERSION
+ARG PGBOUNCER_SHA256
+USER root
+RUN apk add --no-cache build-base pkgconf libevent-dev openssl-dev curl
+WORKDIR /build
+RUN curl -fsSL -o pgbouncer.tar.gz "https://www.pgbouncer.org/downloads/files/${PGBOUNCER_VERSION}/pgbouncer-${PGBOUNCER_VERSION}.tar.gz" && \
+ echo "${PGBOUNCER_SHA256} pgbouncer.tar.gz" | sha256sum -c - && \
+ tar xzf pgbouncer.tar.gz --strip-components=1 && \
+ ./configure --prefix=/usr/local --with-openssl=/usr && \
+ make -j"$(nproc)" pgbouncer && \
+ install -m 0755 pgbouncer /usr/local/bin/pgbouncer
+
# ---------- Builder ----------
FROM $LITELLM_BUILD_IMAGE AS builder
@@ -61,6 +77,10 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--extra bedrock-realtime \
--python python3.13
+# PYTHONPATH=/app makes the source tree shadow the installed package, so the
+# compiled Rust extension must live next to the source or it is never imported.
+RUN cp "$(python -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"/litellm/rust_bridge/_native*.so litellm/rust_bridge/
+
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
@@ -73,7 +93,7 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime
USER root
RUN for i in 1 2 3; do \
- apk add --no-cache bash openssl tzdata python-3.13 libsndfile libatomic && break; \
+ apk add --no-cache bash openssl tzdata python-3.13 libsndfile libatomic libevent && break; \
[ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \
sleep 5; \
done
@@ -90,15 +110,17 @@ ENV HOME=/home/nonroot \
COPY --from=builder --chown=nonroot:nonroot /app /app
COPY --from=builder /opt/prisma /opt/prisma
+COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer
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 && \
- 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"
+ 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" && \
+ python -c "import litellm; from litellm.rust_bridge.loader import native_bridge_available; assert litellm.__file__ == '/app/litellm/__init__.py', litellm.__file__; assert native_bridge_available()"
USER nonroot
EXPOSE 4000/tcp
-ENTRYPOINT ["sh", "-c", "exec /app/docker/component_entrypoint.sh uvicorn gateway.main:app --workers \"${NUM_WORKERS:-1}\" \"$@\"", "--"]
+ENTRYPOINT ["sh", "-c", "exec /app/docker/component_entrypoint.sh python -m gateway.launch --workers \"${NUM_WORKERS:-1}\" \"$@\"", "--"]
CMD ["--host", "0.0.0.0", "--port", "4000"]
diff --git a/gateway/launch.py b/gateway/launch.py
new file mode 100644
index 00000000000..d67432caee1
--- /dev/null
+++ b/gateway/launch.py
@@ -0,0 +1,77 @@
+"""Gateway supervisor: assemble DATABASE_URL, start the in-container PgBouncer, then run uvicorn.
+
+``gateway/main.py`` assembles ``DATABASE_URL`` inside every uvicorn worker, which
+is fine for a plain Postgres URL but not for the pooler: PgBouncer must be
+started exactly once per pod, before the workers fork, and the workers must be
+handed the loopback URL it listens on. A pre-existing ``DATABASE_URL`` wins in
+``DatabaseURLSettings.apply_to_env`` under password auth, and one marked pooled
+wins under token auth too, so exporting it here is enough for every worker to
+pick the pooled URL up unchanged.
+
+Run with:
+ python -m gateway.launch --workers 4 --host 0.0.0.0 --port 4000
+"""
+
+import os
+import sys
+from collections.abc import Callable, Mapping, Sequence
+from typing import Final
+
+from uvicorn.main import main as uvicorn_main
+
+from litellm.proxy.db.db_url_settings import DatabaseURLSettings
+from litellm.proxy.db.pgbouncer import (
+ PgBouncerError,
+ PgBouncerSettings,
+ export_pooled_database_url,
+ start_in_container_pgbouncer,
+)
+
+GATEWAY_APP: Final = "gateway.main:app"
+KEEPALIVE_FLAG: Final = "--timeout-keep-alive"
+
+
+def uvicorn_argv(argv: Sequence[str], environ: Mapping[str, str]) -> tuple[str, ...]:
+ """Honor ``KEEPALIVE_TIMEOUT`` like ``proxy_cli.py`` does, unless the flag was passed explicitly."""
+ keepalive: Final = environ.get("KEEPALIVE_TIMEOUT")
+ if keepalive is None or any(arg == KEEPALIVE_FLAG or arg.startswith(f"{KEEPALIVE_FLAG}=") for arg in argv):
+ return (GATEWAY_APP, *argv)
+ return (GATEWAY_APP, *argv, KEEPALIVE_FLAG, keepalive)
+
+
+def pool_database_url(
+ settings: DatabaseURLSettings,
+ pgbouncer: PgBouncerSettings,
+ environ: Mapping[str, str],
+) -> str | PgBouncerError | None:
+ """Start the in-container PgBouncer and return its loopback URL, or None when ``pgbouncer.enabled`` is off.
+
+ The upstream URL is whatever ``apply_to_env`` assembled from the discrete
+ ``DATABASE_*`` vars (or an operator-pinned ``DATABASE_URL``). Under token
+ auth the pooler mints and renews the upstream token itself.
+ """
+ if not pgbouncer.enabled:
+ return None
+ upstream_url: Final = environ.get("DATABASE_URL")
+ if upstream_url is None:
+ return PgBouncerError("LITELLM_PGBOUNCER_ENABLED is set but no DATABASE_URL could be assembled")
+ return start_in_container_pgbouncer(pgbouncer, upstream_url, token_auth=settings.token_auth())
+
+
+def _serve(argv: Sequence[str]) -> None:
+ uvicorn_main(tuple(argv), prog_name="uvicorn")
+
+
+def main(argv: Sequence[str], serve: Callable[[Sequence[str]], None] = _serve) -> None:
+ settings: Final = DatabaseURLSettings.from_env()
+ settings.apply_to_env()
+ pooled_url: Final = pool_database_url(settings, PgBouncerSettings(), os.environ)
+ if isinstance(pooled_url, PgBouncerError):
+ sys.exit(f"LiteLLM gateway: in-container pgbouncer could not start: {pooled_url.reason}")
+ if pooled_url is not None:
+ export_pooled_database_url(pooled_url)
+ serve(uvicorn_argv(argv, os.environ))
+
+
+if __name__ == "__main__":
+ main(sys.argv[1:])
diff --git a/helm/litellm-helm/templates/_helpers.tpl b/helm/litellm-helm/templates/_helpers.tpl
index 8f2acb20fce..9630633912e 100644
--- a/helm/litellm-helm/templates/_helpers.tpl
+++ b/helm/litellm-helm/templates/_helpers.tpl
@@ -161,3 +161,163 @@ taken before the change, which by that point no longer exists.
{{- fail (printf "postgresql.image.tag must be pinned to an explicit version when db.deployStandalone is true (got %q). An unpinned tag can start a different PostgreSQL major against the existing data directory, which makes the database unreadable and is not recoverable in place. Crossing a major version requires a dump and restore." $tag) -}}
{{- end -}}
{{- end -}}
+
+{{/*
+Environment shared by the proxy container and the opt-in collector sidecar:
+database, pgbouncer, master key, redis, user envVars. Both containers must see
+the same DATABASE_URL and REDIS_* so the sidecar reaches the pod's pgbouncer
+and the same spend transaction buffer.
+*/}}
+{{- define "litellm.proxyEnv" -}}
+- name: HOST
+ value: "{{ .Values.listen | default "0.0.0.0" }}"
+- name: PORT
+ value: {{ .Values.service.port | quote}}
+{{- if .Values.db.deployStandalone }}
+- name: DATABASE_USERNAME
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "litellm.fullname" . }}-dbcredentials
+ key: username
+- name: DATABASE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "litellm.fullname" . }}-dbcredentials
+ key: password
+- name: DATABASE_HOST
+ value: {{ .Release.Name }}-postgresql
+- name: DATABASE_NAME
+ value: litellm
+{{- else if .Values.db.useExisting }}
+- name: DATABASE_USERNAME
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Values.db.secret.name }}
+ key: {{ .Values.db.secret.usernameKey }}
+- name: DATABASE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Values.db.secret.name }}
+ key: {{ .Values.db.secret.passwordKey }}
+- name: DATABASE_HOST
+ {{- if .Values.db.secret.endpointKey }}
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Values.db.secret.name }}
+ key: {{ .Values.db.secret.endpointKey }}
+ {{- else }}
+ value: {{ .Values.db.endpoint }}
+ {{- end }}
+- name: DATABASE_NAME
+ value: {{ .Values.db.database }}
+- name: DATABASE_URL
+ value: {{ .Values.db.url | quote }}
+{{- end }}
+{{- if and .Values.db.useExisting .Values.db.readReplicaUrl .Values.db.secret.readReplicaEndpointKey (not .Values.db.secret.readReplicaUrlKey) }}
+- name: DATABASE_READER_HOST
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Values.db.secret.name }}
+ key: {{ .Values.db.secret.readReplicaEndpointKey }}
+{{- end }}
+{{- if and .Values.db.useExisting .Values.db.secret.readReplicaUrlKey }}
+- name: DATABASE_URL_READ_REPLICA
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Values.db.secret.name }}
+ key: {{ .Values.db.secret.readReplicaUrlKey }}
+{{- else if .Values.db.readReplicaUrl }}
+- name: DATABASE_URL_READ_REPLICA
+ value: {{ .Values.db.readReplicaUrl | quote }}
+{{- end }}
+{{- if .Values.db.connectionPool.enabled }}
+- name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+- name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: {{ .Values.db.connectionPool.maxDbConnections | quote }}
+- name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ value: {{ .Values.db.connectionPool.maxClientConn | quote }}
+{{- end }}
+- name: PROXY_MASTER_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Values.masterkeySecretName | default (printf "%s-masterkey" (include "litellm.fullname" .)) }}
+ key: {{ .Values.masterkeySecretKey | default "masterkey" }}
+{{- if .Values.redis.enabled }}
+- name: REDIS_HOST
+ value: {{ include "litellm.redis.serviceName" . }}
+- name: REDIS_PORT
+ value: {{ include "litellm.redis.port" . | quote }}
+- name: REDIS_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "redis.secretName" .Subcharts.redis }}
+ key: {{include "redis.secretPasswordKey" .Subcharts.redis }}
+{{- end }}
+{{- /*
+ Inject LITELLM_LOG only when envVars does not already define it.
+*/}}
+{{- if and .Values.logLevel (not (hasKey (default dict .Values.envVars) "LITELLM_LOG")) }}
+- name: LITELLM_LOG
+ value: {{ .Values.logLevel | quote }}
+{{- end }}
+{{- if .Values.envVars }}
+{{- range $key, $val := .Values.envVars }}
+- name: {{ $key }}
+ value: {{ $val | quote }}
+{{- end }}
+{{- end }}
+{{- with .Values.extraEnvVars }}
+{{ toYaml . }}
+{{- end }}
+{{- if .Values.migrationJob.enabled }}
+# Schema updates are owned by the dedicated migrations Job; skip
+# the proxy's startup `prisma db push` so N replicas don't race
+# one DB on every rollout. Placed last (after envVars and
+# extraEnvVars) so this override can't be silently shadowed by a
+# user-supplied DISABLE_SCHEMA_UPDATE under last-wins duplicate-env
+# semantics — same pattern the migrations Job uses.
+- name: DISABLE_SCHEMA_UPDATE
+ value: "true"
+{{- end }}
+{{- end -}}
+
+{{/*
+Proxy-only metering and metrics env. The collector sidecar serves no HTTP
+traffic, so it gets neither.
+*/}}
+{{- define "litellm.proxyMetricsEnv" -}}
+{{- if .Values.billingMetrics.enabled }}
+{{ include "litellm.billingMetricsEnv" . }}
+{{- end }}
+{{- if .Values.metricsServer.enabled }}
+{{- if eq (int .Values.metricsServer.port) (int .Values.service.port) }}
+{{- fail "metricsServer.port must differ from service.port" }}
+{{- end }}
+- name: PROMETHEUS_METRICS_PORT
+ value: {{ .Values.metricsServer.port | quote }}
+{{- end }}
+{{- end -}}
+
+{{/*
+Directory of the collector's unix socket, shared between the two containers
+through an emptyDir. Empty when the sidecar is off or uses 127.0.0.1 TCP.
+*/}}
+{{- define "litellm.collector.socketDir" -}}
+{{- if and .Values.collector.enabled (hasPrefix "unix://" .Values.collector.address) -}}
+{{- dir (trimPrefix "unix://" .Values.collector.address) -}}
+{{- end -}}
+{{- end -}}
+
+{{- define "litellm.collectorEnv" -}}
+- name: LITELLM_COLLECTOR_ENABLED
+ value: "true"
+- name: LITELLM_COLLECTOR_ADDRESS
+ value: {{ .Values.collector.address | quote }}
+- name: LITELLM_COLLECTOR_BUFFER_SIZE
+ value: {{ .Values.collector.bufferSize | quote }}
+- name: LITELLM_COLLECTOR_ON_UNAVAILABLE
+ value: {{ .Values.collector.onUnavailable | quote }}
+- name: LITELLM_COLLECTOR_DRAIN_TIMEOUT_SECONDS
+ value: {{ .Values.collector.drainTimeoutSeconds | quote }}
+{{- end -}}
diff --git a/helm/litellm-helm/templates/deployment.yaml b/helm/litellm-helm/templates/deployment.yaml
index f7c918a6827..cf7b3f8a38d 100644
--- a/helm/litellm-helm/templates/deployment.yaml
+++ b/helm/litellm-helm/templates/deployment.yaml
@@ -56,118 +56,10 @@ spec:
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- - name: HOST
- value: "{{ .Values.listen | default "0.0.0.0" }}"
- - name: PORT
- value: {{ .Values.service.port | quote}}
- {{- if .Values.db.deployStandalone }}
- - name: DATABASE_USERNAME
- valueFrom:
- secretKeyRef:
- name: {{ include "litellm.fullname" . }}-dbcredentials
- key: username
- - name: DATABASE_PASSWORD
- valueFrom:
- secretKeyRef:
- name: {{ include "litellm.fullname" . }}-dbcredentials
- key: password
- - name: DATABASE_HOST
- value: {{ .Release.Name }}-postgresql
- - name: DATABASE_NAME
- value: litellm
- {{- else if .Values.db.useExisting }}
- - name: DATABASE_USERNAME
- valueFrom:
- secretKeyRef:
- name: {{ .Values.db.secret.name }}
- key: {{ .Values.db.secret.usernameKey }}
- - name: DATABASE_PASSWORD
- valueFrom:
- secretKeyRef:
- name: {{ .Values.db.secret.name }}
- key: {{ .Values.db.secret.passwordKey }}
- - name: DATABASE_HOST
- {{- if .Values.db.secret.endpointKey }}
- valueFrom:
- secretKeyRef:
- name: {{ .Values.db.secret.name }}
- key: {{ .Values.db.secret.endpointKey }}
- {{- else }}
- value: {{ .Values.db.endpoint }}
- {{- end }}
- - name: DATABASE_NAME
- value: {{ .Values.db.database }}
- - name: DATABASE_URL
- value: {{ .Values.db.url | quote }}
- {{- end }}
- {{- if and .Values.db.useExisting .Values.db.readReplicaUrl .Values.db.secret.readReplicaEndpointKey (not .Values.db.secret.readReplicaUrlKey) }}
- - name: DATABASE_READER_HOST
- valueFrom:
- secretKeyRef:
- name: {{ .Values.db.secret.name }}
- key: {{ .Values.db.secret.readReplicaEndpointKey }}
- {{- end }}
- {{- if and .Values.db.useExisting .Values.db.secret.readReplicaUrlKey }}
- - name: DATABASE_URL_READ_REPLICA
- valueFrom:
- secretKeyRef:
- name: {{ .Values.db.secret.name }}
- key: {{ .Values.db.secret.readReplicaUrlKey }}
- {{- else if .Values.db.readReplicaUrl }}
- - name: DATABASE_URL_READ_REPLICA
- value: {{ .Values.db.readReplicaUrl | quote }}
- {{- end }}
- - name: PROXY_MASTER_KEY
- valueFrom:
- secretKeyRef:
- name: {{ .Values.masterkeySecretName | default (printf "%s-masterkey" (include "litellm.fullname" .)) }}
- key: {{ .Values.masterkeySecretKey | default "masterkey" }}
- {{- if .Values.redis.enabled }}
- - name: REDIS_HOST
- value: {{ include "litellm.redis.serviceName" . }}
- - name: REDIS_PORT
- value: {{ include "litellm.redis.port" . | quote }}
- - name: REDIS_PASSWORD
- valueFrom:
- secretKeyRef:
- name: {{ include "redis.secretName" .Subcharts.redis }}
- key: {{include "redis.secretPasswordKey" .Subcharts.redis }}
- {{- end }}
- {{- /*
- Inject LITELLM_LOG only when envVars does not already define it.
- */}}
- {{- if and .Values.logLevel (not (hasKey (default dict .Values.envVars) "LITELLM_LOG")) }}
- - name: LITELLM_LOG
- value: {{ .Values.logLevel | quote }}
- {{- end }}
- {{- if .Values.envVars }}
- {{- range $key, $val := .Values.envVars }}
- - name: {{ $key }}
- value: {{ $val | quote }}
- {{- end }}
- {{- end }}
- {{- with .Values.extraEnvVars }}
- {{- toYaml . | nindent 12 }}
- {{- end }}
- {{- if .Values.billingMetrics.enabled }}
- {{- include "litellm.billingMetricsEnv" . | nindent 12 }}
- {{- end }}
- {{- if .Values.metricsServer.enabled }}
- {{- if eq (int .Values.metricsServer.port) (int .Values.service.port) }}
- {{- fail "metricsServer.port must differ from service.port" }}
- {{- end }}
- - name: PROMETHEUS_METRICS_PORT
- value: {{ .Values.metricsServer.port | quote }}
- {{- end }}
- {{- if .Values.migrationJob.enabled }}
- # Schema updates are owned by the dedicated migrations Job; skip
- # the proxy's startup `prisma db push` so N replicas don't race
- # one DB on every rollout. Placed last (after envVars and
- # extraEnvVars) so this override can't be silently shadowed by a
- # user-supplied DISABLE_SCHEMA_UPDATE under last-wins duplicate-env
- # semantics — same pattern the migrations Job uses.
- - name: DISABLE_SCHEMA_UPDATE
- value: "true"
+ {{- include "litellm.proxyEnv" . | nindent 12 }}
+ {{- include "litellm.proxyMetricsEnv" . | nindent 12 }}
+ {{- if .Values.collector.enabled }}
+ {{- include "litellm.collectorEnv" . | nindent 12 }}
{{- end }}
envFrom:
{{- range .Values.environmentSecrets }}
@@ -245,6 +137,10 @@ spec:
{{- if .Values.billingMetrics.enabled }}
{{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }}
{{- end }}
+ {{- if include "litellm.collector.socketDir" . }}
+ - name: collector-socket
+ mountPath: {{ include "litellm.collector.socketDir" . }}
+ {{- end }}
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -252,6 +148,53 @@ spec:
lifecycle:
{{- toYaml . | nindent 12 }}
{{- end }}
+ {{- if .Values.collector.enabled }}
+ - name: {{ include "litellm.name" . }}-collector
+ securityContext:
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ command: {{ toYaml .Values.collector.command | nindent 12 }}
+ env:
+ {{- include "litellm.proxyEnv" . | nindent 12 }}
+ {{- include "litellm.collectorEnv" . | nindent 12 }}
+ - name: LITELLM_JOB_ROLE
+ value: collector
+ {{- if not (hasKey (default dict .Values.envVars) "CONFIG_FILE_PATH") }}
+ - name: CONFIG_FILE_PATH
+ value: /etc/litellm/config.yaml
+ {{- end }}
+ envFrom:
+ {{- range .Values.environmentSecrets }}
+ - secretRef:
+ name: {{ . }}
+ {{- end }}
+ {{- range .Values.environmentConfigMaps }}
+ - configMapRef:
+ name: {{ . }}
+ {{- end }}
+ resources:
+ {{- toYaml .Values.collector.resources | nindent 12 }}
+ volumeMounts:
+ - name: litellm-config
+ mountPath: /etc/litellm/config.yaml
+ subPath: config.yaml
+ {{- if include "litellm.collector.socketDir" . }}
+ - name: collector-socket
+ mountPath: {{ include "litellm.collector.socketDir" . }}
+ {{- end }}
+ {{ if .Values.securityContext.readOnlyRootFilesystem }}
+ - name: tmp
+ mountPath: /tmp
+ - name: cache
+ mountPath: /.cache
+ - name: npm
+ mountPath: /.npm
+ {{- end }}
+ {{- with .Values.volumeMounts }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- end }}
{{- with .Values.extraContainers }}
{{- tpl (toYaml .) $ | nindent 8 }}
{{- end }}
@@ -280,6 +223,11 @@ spec:
{{- if .Values.billingMetrics.enabled }}
{{- include "litellm.billingMetricsVolumes" . | nindent 8 }}
{{- end }}
+ {{- if include "litellm.collector.socketDir" . }}
+ - name: collector-socket
+ emptyDir:
+ sizeLimit: 1Mi
+ {{- end }}
{{- with .Values.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
diff --git a/helm/litellm-helm/templates/hpa.yaml b/helm/litellm-helm/templates/hpa.yaml
index fec4d1f5c5e..a651f916d21 100644
--- a/helm/litellm-helm/templates/hpa.yaml
+++ b/helm/litellm-helm/templates/hpa.yaml
@@ -18,6 +18,15 @@ spec:
{{- end }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
+ {{- if and .Values.collector.enabled .Values.collector.scaleOnProxyContainerCpu }}
+ - type: ContainerResource
+ containerResource:
+ name: cpu
+ container: {{ include "litellm.name" . }}
+ target:
+ type: Utilization
+ averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
+ {{- else }}
- type: Resource
resource:
name: cpu
@@ -25,6 +34,7 @@ spec:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
+ {{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
@@ -33,4 +43,22 @@ spec:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
+ {{- with .Values.autoscaling.targetRequestsPerSecond }}
+ - type: Pods
+ pods:
+ metric:
+ name: litellm_requests_per_second
+ target:
+ type: AverageValue
+ averageValue: {{ toJson . | trimAll "\"" | quote }}
+ {{- end }}
+ {{- with .Values.autoscaling.targetTokensPerSecond }}
+ - type: Pods
+ pods:
+ metric:
+ name: litellm_tokens_per_second
+ target:
+ type: AverageValue
+ averageValue: {{ toJson . | trimAll "\"" | quote }}
+ {{- end }}
{{- end }}
diff --git a/helm/litellm-helm/templates/keda.yaml b/helm/litellm-helm/templates/keda.yaml
index fe5190fffc6..bf585d0d4be 100644
--- a/helm/litellm-helm/templates/keda.yaml
+++ b/helm/litellm-helm/templates/keda.yaml
@@ -23,6 +23,27 @@ spec:
triggers:
{{- with .Values.keda.triggers }}
{{- toYaml . | nindent 2 }}
+{{- end }}
+{{- $prom := .Values.keda.prometheus }}
+{{- if or $prom.requestsPerSecond $prom.tokensPerSecond }}
+{{- if not $prom.serverAddress }}
+{{- fail "keda.prometheus.serverAddress is required when keda.prometheus.requestsPerSecond or tokensPerSecond is set" }}
+{{- end }}
+{{- $selector := printf "namespace=%q,job=%q" .Release.Namespace (printf "%s%s" (include "litellm.fullname" .) (ternary "-metrics" "" .Values.metricsServer.enabled)) }}
+{{- with $prom.requestsPerSecond }}
+ - type: prometheus
+ metadata:
+ serverAddress: {{ $prom.serverAddress | quote }}
+ threshold: {{ toJson . | trimAll "\"" | quote }}
+ query: {{ printf "sum(rate(litellm_proxy_total_requests_metric_total{%s}[1m]))" $selector | quote }}
+{{- end }}
+{{- with $prom.tokensPerSecond }}
+ - type: prometheus
+ metadata:
+ serverAddress: {{ $prom.serverAddress | quote }}
+ threshold: {{ toJson . | trimAll "\"" | quote }}
+ query: {{ printf "sum(rate(litellm_total_tokens_metric_total{%s}[1m]))" $selector | quote }}
+{{- end }}
{{- end }}
advanced:
restoreToOriginalReplicaCount: {{ .Values.keda.restoreToOriginalReplicaCount }}
diff --git a/helm/litellm-helm/tests/collector_tests.yaml b/helm/litellm-helm/tests/collector_tests.yaml
new file mode 100644
index 00000000000..0340b1161b7
--- /dev/null
+++ b/helm/litellm-helm/tests/collector_tests.yaml
@@ -0,0 +1,272 @@
+suite: test collector sidecar
+templates:
+ - deployment.yaml
+ - hpa.yaml
+ - configmap-litellm.yaml
+tests:
+ - it: should run the proxy alone with no collector env by default
+ template: deployment.yaml
+ asserts:
+ - lengthEqual:
+ path: spec.template.spec.containers
+ count: 1
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_ENABLED
+ value: "true"
+ - notContains:
+ path: spec.template.spec.volumes
+ content:
+ name: collector-socket
+ any: true
+
+ - it: should add the sidecar on the same image and point both containers at the unix socket
+ template: deployment.yaml
+ set:
+ image.tag: test
+ db.connectionPool.enabled: true
+ collector.enabled: true
+ collector.resources:
+ requests:
+ cpu: 500m
+ memory: 1Gi
+ limits:
+ cpu: "1"
+ memory: 2Gi
+ asserts:
+ - lengthEqual:
+ path: spec.template.spec.containers
+ count: 2
+ - equal:
+ path: spec.template.spec.containers[1].name
+ value: litellm-collector
+ - equal:
+ path: spec.template.spec.containers[1].image
+ value: ghcr.io/berriai/litellm:test
+ - equal:
+ path: spec.template.spec.containers[1].command
+ value: [python, -m, litellm.proxy.collector]
+ - equal:
+ path: spec.template.spec.containers[1].resources.requests.cpu
+ value: 500m
+ - equal:
+ path: spec.template.spec.containers[1].resources.limits.memory
+ value: 2Gi
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_ENABLED
+ value: "true"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_ADDRESS
+ value: unix:///var/run/litellm/collector.sock
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_BUFFER_SIZE
+ value: "1000"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_ON_UNAVAILABLE
+ value: fallback
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_JOB_ROLE
+ value: collector
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_JOB_ROLE
+ value: collector
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_COLLECTOR_ADDRESS
+ value: unix:///var/run/litellm/collector.sock
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: CONFIG_FILE_PATH
+ value: /etc/litellm/config.yaml
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: DATABASE_HOST
+ value: RELEASE-NAME-postgresql
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: DATABASE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: RELEASE-NAME-litellm-dbcredentials
+ key: password
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: "20"
+ - contains:
+ path: spec.template.spec.containers[0].volumeMounts
+ content:
+ name: collector-socket
+ mountPath: /var/run/litellm
+ - contains:
+ path: spec.template.spec.containers[1].volumeMounts
+ content:
+ name: collector-socket
+ mountPath: /var/run/litellm
+ - contains:
+ path: spec.template.spec.containers[1].volumeMounts
+ content:
+ name: litellm-config
+ mountPath: /etc/litellm/config.yaml
+ subPath: config.yaml
+ - contains:
+ path: spec.template.spec.volumes
+ content:
+ name: collector-socket
+ emptyDir:
+ sizeLimit: 1Mi
+
+ - it: should skip the socket volume and pass the policy through on tcp transport
+ template: deployment.yaml
+ set:
+ collector.enabled: true
+ collector.address: tcp://127.0.0.1:4100
+ collector.onUnavailable: drop
+ collector.bufferSize: 50
+ envVars:
+ CONFIG_FILE_PATH: /custom/config.yaml
+ asserts:
+ - lengthEqual:
+ path: spec.template.spec.containers
+ count: 2
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: CONFIG_FILE_PATH
+ value: /etc/litellm/config.yaml
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: CONFIG_FILE_PATH
+ value: /custom/config.yaml
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_ADDRESS
+ value: tcp://127.0.0.1:4100
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_ON_UNAVAILABLE
+ value: drop
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_BUFFER_SIZE
+ value: "50"
+ - notContains:
+ path: spec.template.spec.volumes
+ content:
+ name: collector-socket
+ any: true
+
+ - it: should keep metrics and billing env on the proxy container only
+ template: deployment.yaml
+ set:
+ collector.enabled: true
+ metricsServer.enabled: true
+ metricsServer.port: 9090
+ billingMetrics.enabled: true
+ billingMetrics.endpoint: https://metering.example.com
+ billingMetrics.secretName: billing-mtls
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: PROMETHEUS_METRICS_PORT
+ value: "9090"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_BILLING_METRICS_ENDPOINT
+ value: https://metering.example.com
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: PROMETHEUS_METRICS_PORT
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_BILLING_METRICS_ENDPOINT
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[1].volumeMounts
+ content:
+ name: billing-metrics-mtls
+ any: true
+
+ - it: should give the sidecar the same scratch mounts as the proxy on a read-only root
+ template: deployment.yaml
+ set:
+ collector.enabled: true
+ securityContext.readOnlyRootFilesystem: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[1].volumeMounts
+ content:
+ name: npm
+ mountPath: /.npm
+ - contains:
+ path: spec.template.spec.containers[1].volumeMounts
+ content:
+ name: cache
+ mountPath: /.cache
+ - contains:
+ path: spec.template.spec.containers[1].volumeMounts
+ content:
+ name: tmp
+ mountPath: /tmp
+
+ - it: should keep the pod-wide cpu metric unless asked to scale on the proxy container
+ template: hpa.yaml
+ set:
+ autoscaling.enabled: true
+ collector.enabled: true
+ asserts:
+ - equal: { path: "spec.metrics[0].type", value: Resource }
+ - equal: { path: "spec.metrics[0].resource.name", value: cpu }
+
+ - it: should scale on the proxy container's cpu only when opted in
+ template: hpa.yaml
+ set:
+ autoscaling.enabled: true
+ collector.enabled: true
+ collector.scaleOnProxyContainerCpu: true
+ asserts:
+ - equal: { path: "spec.metrics[0].type", value: ContainerResource }
+ - equal: { path: "spec.metrics[0].containerResource.name", value: cpu }
+ - equal: { path: "spec.metrics[0].containerResource.container", value: litellm }
+ - equal: { path: "spec.metrics[0].containerResource.target.averageUtilization", value: 60 }
+ - isNull: { path: "spec.metrics[0].resource" }
+
+ - it: should not switch to the container metric while the sidecar is off
+ template: hpa.yaml
+ set:
+ autoscaling.enabled: true
+ collector.scaleOnProxyContainerCpu: true
+ asserts:
+ - equal: { path: "spec.metrics[0].type", value: Resource }
diff --git a/helm/litellm-helm/tests/connection_pool_tests.yaml b/helm/litellm-helm/tests/connection_pool_tests.yaml
new file mode 100644
index 00000000000..203082f27ba
--- /dev/null
+++ b/helm/litellm-helm/tests/connection_pool_tests.yaml
@@ -0,0 +1,112 @@
+suite: test in-container connection pool
+templates:
+ - deployment.yaml
+ - configmap-litellm.yaml
+tests:
+ - it: should not emit pgbouncer env vars by default
+ template: deployment.yaml
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: "20"
+
+ - it: should enable the pool with the default sizing when connectionPool.enabled is set
+ template: deployment.yaml
+ set:
+ db.connectionPool.enabled: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: "20"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ value: "1000"
+
+ - it: should pass custom sizing through as strings next to the worker count
+ template: deployment.yaml
+ set:
+ numWorkers: 4
+ db.connectionPool.enabled: true
+ db.connectionPool.maxDbConnections: 8
+ db.connectionPool.maxClientConn: 400
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: "8"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ value: "400"
+ - contains:
+ path: spec.template.spec.containers[0].args
+ content: "4"
+
+ - it: should give the collector sidecar the same pool env as the proxy container
+ template: deployment.yaml
+ set:
+ collector.enabled: true
+ db.connectionPool.enabled: true
+ db.connectionPool.maxDbConnections: 8
+ db.connectionPool.maxClientConn: 400
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[1].name
+ value: litellm-collector
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: "8"
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ value: "400"
+
+ - it: should give the collector sidecar no pool env when the pool is off
+ template: deployment.yaml
+ set:
+ collector.enabled: true
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[1].name
+ value: litellm-collector
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ any: true
diff --git a/helm/litellm-helm/tests/hpa_tests.yaml b/helm/litellm-helm/tests/hpa_tests.yaml
index cd062dd5971..e446f58c8fe 100644
--- a/helm/litellm-helm/tests/hpa_tests.yaml
+++ b/helm/litellm-helm/tests/hpa_tests.yaml
@@ -61,6 +61,84 @@ tests:
- equal: { path: "spec.metrics[1].resource.name", value: memory }
- equal: { path: "spec.metrics[1].resource.target.averageUtilization", value: 80 }
+ - it: "renders no workload metrics by default"
+ set:
+ autoscaling.enabled: true
+ autoscaling.targetMemoryUtilizationPercentage: 80
+ asserts:
+ - lengthEqual: { path: spec.metrics, count: 2 }
+ - notContains: { path: spec.metrics, content: { type: Pods }, any: true }
+
+ - it: "adds a requests-per-second Pods metric after the cpu metric"
+ set:
+ autoscaling.enabled: true
+ autoscaling.targetRequestsPerSecond: 90
+ asserts:
+ - lengthEqual: { path: spec.metrics, count: 2 }
+ - equal: { path: "spec.metrics[0].resource.name", value: cpu }
+ - equal:
+ path: "spec.metrics[1]"
+ value:
+ type: Pods
+ pods:
+ metric: { name: litellm_requests_per_second }
+ target: { type: AverageValue, averageValue: "90" }
+
+ - it: "adds a tokens-per-second Pods metric on its own"
+ set:
+ autoscaling.enabled: true
+ autoscaling.targetTokensPerSecond: 6M
+ asserts:
+ - lengthEqual: { path: spec.metrics, count: 2 }
+ - equal:
+ path: "spec.metrics[1]"
+ value:
+ type: Pods
+ pods:
+ metric: { name: litellm_tokens_per_second }
+ target: { type: AverageValue, averageValue: "6M" }
+ - notContains:
+ path: spec.metrics
+ content: { type: Pods, pods: { metric: { name: litellm_requests_per_second } } }
+ any: true
+
+ - it: "renders requests, tokens, cpu and memory metrics together"
+ set:
+ autoscaling.enabled: true
+ autoscaling.targetMemoryUtilizationPercentage: 80
+ autoscaling.targetRequestsPerSecond: 90
+ autoscaling.targetTokensPerSecond: 6000000
+ asserts:
+ - lengthEqual: { path: spec.metrics, count: 4 }
+ - equal: { path: "spec.metrics[0].resource.name", value: cpu }
+ - equal: { path: "spec.metrics[1].resource.name", value: memory }
+ - equal: { path: "spec.metrics[2].pods.metric.name", value: litellm_requests_per_second }
+ - equal: { path: "spec.metrics[2].pods.target.averageValue", value: "90" }
+ - equal: { path: "spec.metrics[3].pods.metric.name", value: litellm_tokens_per_second }
+ - equal: { path: "spec.metrics[3].pods.target.averageValue", value: "6000000" }
+
+ - it: "scales on workload metrics alone when the cpu target is cleared"
+ set:
+ autoscaling.enabled: true
+ autoscaling.targetCPUUtilizationPercentage: null
+ autoscaling.targetRequestsPerSecond: 90
+ autoscaling.targetTokensPerSecond: 6000000
+ asserts:
+ - lengthEqual: { path: spec.metrics, count: 2 }
+ - notContains: { path: spec.metrics, content: { type: Resource }, any: true }
+ - equal: { path: "spec.metrics[0].pods.metric.name", value: litellm_requests_per_second }
+ - equal: { path: "spec.metrics[1].pods.metric.name", value: litellm_tokens_per_second }
+ - notMatchRegexRaw: { pattern: per_minute }
+
+ - it: "ignores the per-minute keys, which the chart never shipped"
+ set:
+ autoscaling.enabled: true
+ autoscaling.targetRequestsPerMinute: 5400
+ autoscaling.targetTokensPerMinute: 360000000
+ asserts:
+ - lengthEqual: { path: spec.metrics, count: 1 }
+ - notContains: { path: spec.metrics, content: { type: Pods }, any: true }
+
- it: "renders no hpa when autoscaling is disabled"
asserts:
- hasDocuments: { count: 0 }
diff --git a/helm/litellm-helm/tests/keda_tests.yaml b/helm/litellm-helm/tests/keda_tests.yaml
new file mode 100644
index 00000000000..c9598646223
--- /dev/null
+++ b/helm/litellm-helm/tests/keda_tests.yaml
@@ -0,0 +1,106 @@
+suite: "keda"
+templates:
+ - keda.yaml
+release:
+ name: rel
+ namespace: llm
+tests:
+ - it: "renders no scaled object by default"
+ asserts:
+ - hasDocuments: { count: 0 }
+
+ - it: "passes user triggers through and adds no prometheus triggers by default"
+ set:
+ keda.enabled: true
+ keda.triggers:
+ - type: cpu
+ metricType: Utilization
+ metadata: { value: "60" }
+ asserts:
+ - isKind: { of: ScaledObject }
+ - equal:
+ path: spec.triggers
+ value:
+ - type: cpu
+ metricType: Utilization
+ metadata: { value: "60" }
+
+ - it: "scales on release-wide requests per second divided by the per-replica target"
+ set:
+ keda.enabled: true
+ keda.prometheus.serverAddress: http://prometheus-operated.monitoring.svc:9090
+ keda.prometheus.requestsPerSecond: 90
+ asserts:
+ - lengthEqual: { path: spec.triggers, count: 1 }
+ - equal:
+ path: "spec.triggers[0]"
+ value:
+ type: prometheus
+ metadata:
+ serverAddress: http://prometheus-operated.monitoring.svc:9090
+ threshold: "90"
+ query: sum(rate(litellm_proxy_total_requests_metric_total{namespace="llm",job="rel-litellm"}[1m]))
+
+ - it: "scales on tokens per second on its own"
+ set:
+ keda.enabled: true
+ keda.prometheus.serverAddress: http://prom:9090
+ keda.prometheus.tokensPerSecond: 6000000
+ asserts:
+ - lengthEqual: { path: spec.triggers, count: 1 }
+ - equal: { path: "spec.triggers[0].type", value: prometheus }
+ - equal: { path: "spec.triggers[0].metadata.threshold", value: "6000000" }
+ - equal:
+ path: "spec.triggers[0].metadata.query"
+ value: sum(rate(litellm_total_tokens_metric_total{namespace="llm",job="rel-litellm"}[1m]))
+
+ - it: "appends requests and tokens triggers after user triggers and selects the metrics service job"
+ set:
+ keda.enabled: true
+ metricsServer.enabled: true
+ keda.triggers:
+ - type: cpu
+ metricType: Utilization
+ metadata: { value: "60" }
+ keda.prometheus.serverAddress: http://prom:9090
+ keda.prometheus.requestsPerSecond: 90
+ keda.prometheus.tokensPerSecond: 6000000
+ asserts:
+ - lengthEqual: { path: spec.triggers, count: 3 }
+ - equal: { path: "spec.triggers[0].type", value: cpu }
+ - equal: { path: "spec.triggers[1].metadata.threshold", value: "90" }
+ - equal:
+ path: "spec.triggers[1].metadata.query"
+ value: sum(rate(litellm_proxy_total_requests_metric_total{namespace="llm",job="rel-litellm-metrics"}[1m]))
+ - equal: { path: "spec.triggers[2].metadata.threshold", value: "6000000" }
+ - equal:
+ path: "spec.triggers[2].metadata.query"
+ value: sum(rate(litellm_total_tokens_metric_total{namespace="llm",job="rel-litellm-metrics"}[1m]))
+ - notMatchRegexRaw: { pattern: "\\* *60|per_minute|PerMinute" }
+
+ - it: "ignores the per-minute keys, which the chart never shipped"
+ set:
+ keda.enabled: true
+ keda.prometheus.serverAddress: http://prom:9090
+ keda.prometheus.requestsPerMinute: 5400
+ keda.prometheus.tokensPerMinute: 360000000
+ asserts:
+ - isKind: { of: ScaledObject }
+ - isNullOrEmpty: { path: spec.triggers }
+
+ - it: "refuses a workload target without a prometheus server address"
+ set:
+ keda.enabled: true
+ keda.prometheus.requestsPerSecond: 90
+ asserts:
+ - failedTemplate:
+ errorMessage: keda.prometheus.serverAddress is required when keda.prometheus.requestsPerSecond or tokensPerSecond is set
+
+ - it: "yields to the hpa when both autoscalers are enabled"
+ set:
+ autoscaling.enabled: true
+ keda.enabled: true
+ keda.prometheus.serverAddress: http://prom:9090
+ keda.prometheus.requestsPerSecond: 90
+ asserts:
+ - hasDocuments: { count: 0 }
diff --git a/helm/litellm-helm/values.yaml b/helm/litellm-helm/values.yaml
index 8dc7b967e11..fcee331a5aa 100644
--- a/helm/litellm-helm/values.yaml
+++ b/helm/litellm-helm/values.yaml
@@ -190,6 +190,48 @@ metricsServer:
enabled: false
port: 4001
+# Opt-in sidecar that runs the post-response spend pipeline (cost calculation,
+# spend logs, spend counters, budget reservation reconciliation) so the proxy's
+# uvicorn workers only serialise a compact typed event and go back to serving
+# inference. Same image and tag as the proxy, second container in the same pod,
+# fed over loopback (a unix socket on a shared emptyDir, or 127.0.0.1 TCP). It
+# reuses the pod's in-container pgbouncer (db.connectionPool) and the same Redis
+# spend transaction buffer, so the per-pod DB connection budget is unchanged.
+# Delivery is at-most-once inside the pod: events already handed to the sidecar
+# are lost if it crashes before writing them; events the workers could not hand
+# over follow onUnavailable. Both containers drain on SIGTERM within
+# terminationGracePeriodSeconds
+collector:
+ enabled: false
+ # unix:///
/.sock (the becomes a shared emptyDir) or tcp://127.0.0.1:
+ address: unix:///var/run/litellm/collector.sock
+ # Events each uvicorn worker holds in memory while the sidecar is slow or restarting
+ bufferSize: 1000
+ # fallback: run the pipeline in the worker when the sidecar is unreachable or the
+ # buffer is full (spend stays exact, that request costs proxy CPU again)
+ # drop: count and discard the event instead (spend under-reports)
+ onUnavailable: fallback
+ # How long the workers keep pushing buffered events on shutdown, and how long the
+ # sidecar keeps serving its open connections after SIGTERM
+ drainTimeoutSeconds: 10
+ command:
+ - python
+ - -m
+ - litellm.proxy.collector
+ # Sized independently of the proxy container; the pipeline is CPU bound
+ resources: {}
+ # requests:
+ # cpu: 500m
+ # memory: 1Gi
+ # limits:
+ # cpu: "1"
+ # memory: 2Gi
+ # When autoscaling.enabled, swap the pod-wide cpu Resource metric for an
+ # autoscaling/v2 ContainerResource metric on the proxy container only, so the
+ # sidecar's CPU never scales inference replicas. Needs Kubernetes 1.30+ (or the
+ # HPAContainerMetrics feature gate on 1.27 to 1.29)
+ scaleOnProxyContainerCpu: false
+
resources:
{}
# Unset by default so the chart installs on small clusters such as Minikube, and so an
@@ -222,6 +264,25 @@ autoscaling:
# Memory is a floor to provision under 'resources', not a signal to scale on.
# targetMemoryUtilizationPercentage: 80
# behavior: {}
+ # Opt-in per-pod workload targets, rendered as autoscaling/v2 `Pods` metrics
+ # named `litellm_requests_per_second` and `litellm_tokens_per_second` with an
+ # AverageValue target, alongside whichever resource targets are set (the HPA
+ # follows the metric asking for the most replicas). A Prometheus Adapter must
+ # serve those two names on custom.metrics.k8s.io from the proxy's counters,
+ # grouped by the scrape target's `pod` label (enable serviceMonitor below so
+ # every pod is scraped on its own):
+ # litellm_requests_per_second:
+ # sum(rate(litellm_proxy_total_requests_metric_total{<<.LabelMatchers>>}[1m])) by (<<.GroupBy>>)
+ # litellm_tokens_per_second:
+ # sum(rate(litellm_total_tokens_metric_total{<<.LabelMatchers>>}[1m])) by (<<.GroupBy>>)
+ # rate() over [1m] is already per second, so no `* 60`. How fast the HPA
+ # reacts is set by that window, the scrape interval and the HPA sync period
+ # (15s by default), not by the unit: keep serviceMonitor.interval at 15s or
+ # faster so a 1m window holds at least 4 samples. averageValue takes SI
+ # suffixes, so "6M" is six million tokens per second per pod. Tokens are
+ # counted when a response completes, so TPS trails long streams.
+ targetRequestsPerSecond: ""
+ targetTokensPerSecond: ""
# Autoscaling with keda is mutually exclusive with hpa
keda:
@@ -243,6 +304,23 @@ keda:
# metricName: http_requests_total
# threshold: '100'
# query: sum(rate(http_requests_total{deployment="my-deployment"}[2m]))
+ # First-class Prometheus triggers on the proxy's own request and token
+ # counters, appended to `triggers`. Each target is the per-second load one
+ # replica should carry: KEDA divides the release-wide
+ # `sum(rate([1m]))` by it to pick the replica count. Thresholds
+ # are plain numbers (KEDA parses them as floats, no SI suffixes). The
+ # queries select samples by the release namespace and the `job` label the
+ # chart's ServiceMonitor produces (the metrics Service name), so enable
+ # serviceMonitor below together with metricsServer: the http port serves
+ # /metrics/ behind virtual-key auth and answers an unauthenticated scrape
+ # with 401. Reaction time comes from the [1m] window, the scrape interval
+ # and pollingInterval above, so keep both at 15s or faster. Tokens are
+ # counted at completion, so TPS trails long streams. serverAddress is
+ # required once either target is set.
+ prometheus:
+ serverAddress: ""
+ requestsPerSecond: ""
+ tokensPerSecond: ""
behavior: {}
# scaleDown:
# stabilizationWindowSeconds: 300
@@ -319,6 +397,20 @@ db:
# only (e.g. when IAM_TOKEN_DB_AUTH supplies the token at runtime).
readReplicaUrl: ""
+ # In-container connection pool (PgBouncer, transaction mode) shared by every
+ # worker in the pod. Without it each --num_workers worker opens its own
+ # connection_limit connections to Postgres, so a pod's footprint against the
+ # database's connection ceiling is workers x connection_limit and grows with
+ # every replica. With it, the pod holds at most maxDbConnections upstream
+ # connections no matter how many workers run; the workers connect to the pool
+ # over loopback, with no extra network hop. Migrations still go straight to
+ # Postgres. Starting profile for numWorkers: 4 is maxDbConnections: 20, so
+ # a database with a 5000-connection ceiling fits roughly 200 replicas.
+ connectionPool:
+ enabled: false
+ maxDbConnections: 20
+ maxClientConn: 1000
+
# Use the Stackgres Helm chart to deploy an instance of a Stackgres cluster.
# The Stackgres Operator must already be installed within the target
# Kubernetes cluster.
diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl
index c459512c7b9..692a799e783 100644
--- a/helm/litellm/templates/_helpers.tpl
+++ b/helm/litellm/templates/_helpers.tpl
@@ -360,6 +360,20 @@ harmless no-op for the Job and authoritative for the app pods.
{{- end }}
{{- end -}}
+{{/*
+In-container PgBouncer env for the gateway container. Under IAM or Entra auth the pooler mints and renews the database token itself.
+*/}}
+{{- define "litellm.connectionPoolEnv" -}}
+{{- with .Values.database.connectionPool -}}
+- name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+- name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: {{ required "database.connectionPool.maxDbConnections is required when the pool is enabled" .maxDbConnections | quote }}
+- name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ value: {{ required "database.connectionPool.maxClientConn is required when the pool is enabled" .maxClientConn | quote }}
+{{- end }}
+{{- end -}}
+
{{/*
PodDisruptionBudget shared by gateway, backend, and ui.
@@ -443,3 +457,34 @@ ImplementationSpecific
{{- end -}}
{{- define "litellm.gateway.prometheusMultiprocDir" -}}/tmp/litellm_prometheus_multiproc{{- end -}}
+
+{{/*
+Directory of the collector's unix socket, shared by the gateway and
+collector containers through an emptyDir. Empty when the sidecar is off
+or gateway.collector.address is a tcp://127.0.0.1: address.
+*/}}
+{{- define "litellm.gateway.collectorSocketDir" -}}
+{{- if and .Values.gateway.collector.enabled (hasPrefix "unix://" .Values.gateway.collector.address) -}}
+{{- dir (trimPrefix "unix://" .Values.gateway.collector.address) -}}
+{{- end -}}
+{{- end -}}
+
+{{/*
+LITELLM_COLLECTOR_* env shared by the producer (gateway container) and the
+consumer (collector container), so both agree on the transport and the
+shutdown drain window.
+*/}}
+{{- define "litellm.gateway.collectorEnv" -}}
+{{- with .Values.gateway.collector }}
+- name: LITELLM_COLLECTOR_ENABLED
+ value: "true"
+- name: LITELLM_COLLECTOR_ADDRESS
+ value: {{ .address | quote }}
+- name: LITELLM_COLLECTOR_BUFFER_SIZE
+ value: {{ .bufferSize | quote }}
+- name: LITELLM_COLLECTOR_ON_UNAVAILABLE
+ value: {{ .onUnavailable | quote }}
+- name: LITELLM_COLLECTOR_DRAIN_TIMEOUT_SECONDS
+ value: {{ .drainTimeoutSeconds | quote }}
+{{- end }}
+{{- end -}}
diff --git a/helm/litellm/templates/gateway/deployment.yaml b/helm/litellm/templates/gateway/deployment.yaml
index 9cb6b07e77b..c06cc9583a0 100644
--- a/helm/litellm/templates/gateway/deployment.yaml
+++ b/helm/litellm/templates/gateway/deployment.yaml
@@ -61,6 +61,9 @@ spec:
- name: NUM_WORKERS
value: {{ .Values.gateway.numWorkers | quote }}
{{- end }}
+ {{- if .Values.database.connectionPool.enabled }}
+ {{- include "litellm.connectionPoolEnv" $ | nindent 12 }}
+ {{- end }}
{{- if .Values.billingMetrics.enabled }}
{{- include "litellm.billingMetricsEnv" . | nindent 12 }}
{{- end }}
@@ -71,8 +74,11 @@ spec:
- name: PROMETHEUS_MULTIPROC_DIR
value: {{ include "litellm.gateway.prometheusMultiprocDir" . }}
{{- end }}
+ {{- if .Values.gateway.collector.enabled }}
+ {{- include "litellm.gateway.collectorEnv" . | nindent 12 }}
+ {{- end }}
{{- include "litellm.envFrom" .Values.gateway | nindent 10 }}
- {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.billingMetrics.enabled .Values.gateway.metricsServer.enabled }}
+ {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.billingMetrics.enabled .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }}
volumeMounts:
{{- if .Values.gateway.config.create }}
- name: gateway-config
@@ -83,6 +89,10 @@ spec:
- name: prometheus-multiproc
mountPath: {{ include "litellm.gateway.prometheusMultiprocDir" . }}
{{- end }}
+ {{- if include "litellm.gateway.collectorSocketDir" . }}
+ - name: collector-socket
+ mountPath: {{ include "litellm.gateway.collectorSocketDir" . }}
+ {{- end }}
{{- if .Values.billingMetrics.enabled }}
{{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }}
{{- end }}
@@ -142,10 +152,53 @@ spec:
resources:
{{- toYaml .Values.gateway.metricsServer.resources | nindent 12 }}
{{- end }}
+ {{- if .Values.gateway.collector.enabled }}
+ - name: collector
+ 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 }}
+ command:
+ - python
+ - -m
+ - litellm.proxy.collector
+ env:
+ {{- include "litellm.serverEnv" (dict "root" $ "component" .Values.gateway) | nindent 12 }}
+ {{- if .Values.gateway.config.create }}
+ - name: CONFIG_FILE_PATH
+ value: /app/config/config.yaml
+ {{- end }}
+ {{- if .Values.database.connectionPool.enabled }}
+ {{- include "litellm.connectionPoolEnv" $ | nindent 12 }}
+ {{- end }}
+ {{- include "litellm.gateway.collectorEnv" . | nindent 12 }}
+ - name: LITELLM_JOB_ROLE
+ value: collector
+ {{- include "litellm.envFrom" .Values.gateway | nindent 10 }}
+ {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts (include "litellm.gateway.collectorSocketDir" .) }}
+ volumeMounts:
+ {{- if .Values.gateway.config.create }}
+ - name: gateway-config
+ mountPath: /app/config/config.yaml
+ subPath: config.yaml
+ {{- end }}
+ {{- if include "litellm.gateway.collectorSocketDir" . }}
+ - name: collector-socket
+ mountPath: {{ include "litellm.gateway.collectorSocketDir" . }}
+ {{- end }}
+ {{- with .Values.gateway.volumeMounts }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- end }}
+ resources:
+ {{- toYaml .Values.gateway.collector.resources | nindent 12 }}
+ {{- end }}
{{- with .Values.gateway.extraContainers }}
{{- tpl (toYaml .) $ | nindent 8 }}
{{- end }}
- {{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.billingMetrics.enabled .Values.gateway.metricsServer.enabled }}
+ {{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.billingMetrics.enabled .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }}
volumes:
{{- if .Values.gateway.config.create }}
- name: gateway-config
@@ -156,6 +209,11 @@ spec:
- name: prometheus-multiproc
emptyDir: {}
{{- end }}
+ {{- if include "litellm.gateway.collectorSocketDir" . }}
+ - name: collector-socket
+ emptyDir:
+ sizeLimit: 1Mi
+ {{- end }}
{{- if .Values.billingMetrics.enabled }}
{{- include "litellm.billingMetricsVolumes" . | nindent 8 }}
{{- end }}
diff --git a/helm/litellm/templates/gateway/hpa.yaml b/helm/litellm/templates/gateway/hpa.yaml
index e97cef95ffb..e7094e96106 100644
--- a/helm/litellm/templates/gateway/hpa.yaml
+++ b/helm/litellm/templates/gateway/hpa.yaml
@@ -15,6 +15,15 @@ spec:
maxReplicas: {{ .Values.gateway.hpa.maxReplicas }}
metrics:
{{- if .Values.gateway.hpa.targetCPUUtilizationPercentage }}
+ {{- if and .Values.gateway.collector.enabled .Values.gateway.collector.scaleOnGatewayContainerCpu }}
+ - type: ContainerResource
+ containerResource:
+ name: cpu
+ container: gateway
+ target:
+ type: Utilization
+ averageUtilization: {{ .Values.gateway.hpa.targetCPUUtilizationPercentage }}
+ {{- else }}
- type: Resource
resource:
name: cpu
@@ -22,6 +31,7 @@ spec:
type: Utilization
averageUtilization: {{ .Values.gateway.hpa.targetCPUUtilizationPercentage }}
{{- end }}
+ {{- end }}
{{- if .Values.gateway.hpa.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
@@ -30,6 +40,24 @@ spec:
type: Utilization
averageUtilization: {{ .Values.gateway.hpa.targetMemoryUtilizationPercentage }}
{{- end }}
+ {{- with .Values.gateway.hpa.targetRequestsPerSecond }}
+ - type: Pods
+ pods:
+ metric:
+ name: litellm_requests_per_second
+ target:
+ type: AverageValue
+ averageValue: {{ toJson . | trimAll "\"" | quote }}
+ {{- end }}
+ {{- with .Values.gateway.hpa.targetTokensPerSecond }}
+ - type: Pods
+ pods:
+ metric:
+ name: litellm_tokens_per_second
+ target:
+ type: AverageValue
+ averageValue: {{ toJson . | trimAll "\"" | quote }}
+ {{- end }}
{{- with .Values.gateway.hpa.behavior }}
behavior:
{{- toYaml . | nindent 4 }}
diff --git a/helm/litellm/templates/gateway/servicemonitor.yaml b/helm/litellm/templates/gateway/servicemonitor.yaml
new file mode 100644
index 00000000000..e1bafa6e388
--- /dev/null
+++ b/helm/litellm/templates/gateway/servicemonitor.yaml
@@ -0,0 +1,28 @@
+{{- if and .Values.gateway.enabled .Values.gateway.serviceMonitor.enabled }}
+{{- if not .Values.gateway.metricsServer.enabled }}
+{{- fail "gateway.serviceMonitor.enabled requires gateway.metricsServer.enabled: the http port serves /metrics/ behind virtual-key auth, so an unauthenticated scrape gets 401" }}
+{{- end }}
+apiVersion: monitoring.coreos.com/v1
+kind: ServiceMonitor
+metadata:
+ name: {{ include "litellm.gateway.fullname" . }}
+ labels:
+ {{- include "litellm.commonLabels" . | nindent 4 }}
+ app.kubernetes.io/component: gateway
+ {{- with .Values.gateway.serviceMonitor.labels }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ selector:
+ matchLabels:
+ {{- include "litellm.gateway.selectorLabels" . | nindent 6 }}
+ namespaceSelector:
+ matchNames:
+ - {{ .Release.Namespace | quote }}
+ endpoints:
+ - port: metrics
+ path: /metrics/
+ interval: {{ .Values.gateway.serviceMonitor.interval }}
+ scrapeTimeout: {{ .Values.gateway.serviceMonitor.scrapeTimeout }}
+ scheme: http
+{{- end }}
diff --git a/helm/litellm/tests/collector_tests.yaml b/helm/litellm/tests/collector_tests.yaml
new file mode 100644
index 00000000000..4ef7e3c8ca4
--- /dev/null
+++ b/helm/litellm/tests/collector_tests.yaml
@@ -0,0 +1,216 @@
+suite: test gateway collector sidecar
+templates:
+ - gateway/configmap.yaml
+ - gateway/deployment.yaml
+ - gateway/hpa.yaml
+values:
+ - ./values/required.yaml
+tests:
+ - it: adds no sidecar, env, volume or container metric when the collector is off
+ asserts:
+ - lengthEqual:
+ path: spec.template.spec.containers
+ count: 1
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_ENABLED
+ value: "true"
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.volumes
+ content:
+ name: collector-socket
+ any: true
+ template: gateway/deployment.yaml
+ - equal:
+ path: spec.metrics[0].type
+ value: Resource
+ template: gateway/hpa.yaml
+
+ - it: runs the collector as a sidecar sharing env, config, the pod pool and a unix socket emptyDir, and scales on the gateway container only
+ set:
+ gateway.collector.enabled: true
+ gateway.collector.bufferSize: 250
+ gateway.collector.onUnavailable: drop
+ gateway.image.tag: v1.102.0
+ gateway.numWorkers: 4
+ database.connectionPool.enabled: true
+ database.connectionPool.maxDbConnections: 8
+ database.connectionPool.maxClientConn: 250
+ gateway.envSecrets:
+ - litellm-license
+ gateway.volumes:
+ - name: redis-ca
+ secret:
+ secretName: redis-ca
+ gateway.volumeMounts:
+ - name: redis-ca
+ mountPath: /etc/litellm/redis-ca
+ readOnly: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_ADDRESS
+ value: unix:///var/run/litellm/collector.sock
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_BUFFER_SIZE
+ value: "250"
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_COLLECTOR_ON_UNAVAILABLE
+ value: drop
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[0].volumeMounts
+ content:
+ name: collector-socket
+ mountPath: /var/run/litellm
+ template: gateway/deployment.yaml
+ - equal:
+ path: spec.template.spec.containers[1].name
+ value: collector
+ template: gateway/deployment.yaml
+ - equal:
+ path: spec.template.spec.containers[1].image
+ value: ghcr.io/berriai/litellm-gateway:v1.102.0
+ template: gateway/deployment.yaml
+ - equal:
+ path: spec.template.spec.containers[1].command
+ value:
+ - python
+ - -m
+ - litellm.proxy.collector
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_JOB_ROLE
+ value: collector
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: CONFIG_FILE_PATH
+ value: /app/config/config.yaml
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: "8"
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ value: "250"
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: DATABASE_HOST
+ value: postgres.example.com
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_COLLECTOR_ADDRESS
+ value: unix:///var/run/litellm/collector.sock
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: NUM_WORKERS
+ any: true
+ template: gateway/deployment.yaml
+ - equal:
+ path: spec.template.spec.containers[1].envFrom
+ value:
+ - secretRef:
+ name: litellm-license
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[1].volumeMounts
+ content:
+ name: gateway-config
+ mountPath: /app/config/config.yaml
+ subPath: config.yaml
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[1].volumeMounts
+ content:
+ name: collector-socket
+ mountPath: /var/run/litellm
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[1].volumeMounts
+ content:
+ name: redis-ca
+ mountPath: /etc/litellm/redis-ca
+ readOnly: true
+ template: gateway/deployment.yaml
+ - equal:
+ path: spec.template.spec.containers[1].resources.limits.cpu
+ value: "1"
+ template: gateway/deployment.yaml
+ - contains:
+ path: spec.template.spec.volumes
+ content:
+ name: collector-socket
+ emptyDir:
+ sizeLimit: 1Mi
+ template: gateway/deployment.yaml
+ - equal:
+ path: spec.metrics[0]
+ value:
+ type: ContainerResource
+ containerResource:
+ name: cpu
+ container: gateway
+ target:
+ type: Utilization
+ averageUtilization: 70
+ template: gateway/hpa.yaml
+
+ - it: uses loopback tcp without a socket volume and keeps the pod-wide cpu metric when asked
+ set:
+ gateway.collector.enabled: true
+ gateway.collector.address: tcp://127.0.0.1:4010
+ gateway.collector.scaleOnGatewayContainerCpu: false
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_COLLECTOR_ADDRESS
+ value: tcp://127.0.0.1:4010
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.volumes
+ content:
+ name: collector-socket
+ any: true
+ template: gateway/deployment.yaml
+ - notContains:
+ path: spec.template.spec.containers[1].volumeMounts
+ content:
+ name: collector-socket
+ any: true
+ template: gateway/deployment.yaml
+ - equal:
+ path: spec.metrics[0].type
+ value: Resource
+ template: gateway/hpa.yaml
diff --git a/helm/litellm/tests/connection_pool_tests.yaml b/helm/litellm/tests/connection_pool_tests.yaml
new file mode 100644
index 00000000000..c39651a52c9
--- /dev/null
+++ b/helm/litellm/tests/connection_pool_tests.yaml
@@ -0,0 +1,204 @@
+suite: test in-container connection pool env vars
+templates:
+ - gateway/deployment.yaml
+ - gateway/configmap.yaml
+ - backend/deployment.yaml
+ - backend/configmap.yaml
+values:
+ - ./values/required.yaml
+tests:
+ - it: renders no pool env by default
+ template: gateway/deployment.yaml
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ any: true
+
+ - it: enabled pool renders the three pgbouncer vars with the configured sizes
+ template: gateway/deployment.yaml
+ set:
+ gateway.numWorkers: 4
+ database.connectionPool.enabled: true
+ database.connectionPool.maxDbConnections: 8
+ database.connectionPool.maxClientConn: 250
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: "8"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ value: "250"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: NUM_WORKERS
+ value: "4"
+
+ - it: enabled pool uses the chart default sizes
+ template: gateway/deployment.yaml
+ set:
+ database.connectionPool.enabled: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: "20"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ value: "1000"
+
+ - it: backend never gets the pool env
+ template: backend/deployment.yaml
+ set:
+ database.connectionPool.enabled: true
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ any: true
+
+ - it: collector sidecar gets the same pool env as the gateway container, the metrics sidecar none
+ template: gateway/deployment.yaml
+ set:
+ gateway.collector.enabled: true
+ gateway.metricsServer.enabled: true
+ database.connectionPool.enabled: true
+ database.connectionPool.maxDbConnections: 8
+ database.connectionPool.maxClientConn: 250
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[1].name
+ value: metrics
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ any: true
+ - equal:
+ path: spec.template.spec.containers[2].name
+ value: collector
+ - contains:
+ path: spec.template.spec.containers[2].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ - contains:
+ path: spec.template.spec.containers[2].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ value: "8"
+ - contains:
+ path: spec.template.spec.containers[2].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ value: "250"
+
+ - it: collector sidecar gets no pool env when the pool is off
+ template: gateway/deployment.yaml
+ set:
+ gateway.collector.enabled: true
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[1].name
+ value: collector
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS
+ any: true
+ - notContains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_MAX_CLIENT_CONN
+ any: true
+
+ - it: pool with IAM auth renders both the pool and the token auth flag
+ template: gateway/deployment.yaml
+ set:
+ gateway.collector.enabled: true
+ database.connectionPool.enabled: true
+ database.writer.useIAMAuth: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: IAM_TOKEN_DB_AUTH
+ value: "true"
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ - contains:
+ path: spec.template.spec.containers[1].env
+ content:
+ name: IAM_TOKEN_DB_AUTH
+ value: "true"
+
+ - it: pool with Entra auth renders both the pool and the token auth flag
+ template: gateway/deployment.yaml
+ set:
+ database.connectionPool.enabled: true
+ database.writer.useAzureEntraAuth: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ value: "true"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: AZURE_POSTGRESQL_AUTH
+ value: "true"
+
+ - it: IAM auth without the pool still renders
+ template: gateway/deployment.yaml
+ set:
+ database.writer.useIAMAuth: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: IAM_TOKEN_DB_AUTH
+ value: "true"
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: LITELLM_PGBOUNCER_ENABLED
+ any: true
diff --git a/helm/litellm/tests/hpa_workload_metrics_tests.yaml b/helm/litellm/tests/hpa_workload_metrics_tests.yaml
new file mode 100644
index 00000000000..a29c53c5ed9
--- /dev/null
+++ b/helm/litellm/tests/hpa_workload_metrics_tests.yaml
@@ -0,0 +1,213 @@
+suite: test gateway HPA per-pod requests-per-second and tokens-per-second targets
+templates:
+ - gateway/hpa.yaml
+ - gateway/servicemonitor.yaml
+values:
+ - ./values/required.yaml
+tests:
+ - it: scales on CPU and memory only by default
+ template: gateway/hpa.yaml
+ asserts:
+ - equal:
+ path: spec.metrics
+ value:
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: 70
+ - type: Resource
+ resource:
+ name: memory
+ target:
+ type: Utilization
+ averageUtilization: 80
+
+ - it: adds a requests-per-second Pods metric next to the resource metrics
+ template: gateway/hpa.yaml
+ set:
+ gateway.hpa.targetRequestsPerSecond: 90
+ asserts:
+ - lengthEqual:
+ path: spec.metrics
+ count: 3
+ - contains:
+ path: spec.metrics
+ content:
+ type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: 70
+ - equal:
+ path: spec.metrics[2]
+ value:
+ type: Pods
+ pods:
+ metric:
+ name: litellm_requests_per_second
+ target:
+ type: AverageValue
+ averageValue: "90"
+ - notContains:
+ path: spec.metrics
+ content:
+ type: Pods
+ pods:
+ metric:
+ name: litellm_tokens_per_second
+ any: true
+
+ - it: adds a tokens-per-second Pods metric on its own
+ template: gateway/hpa.yaml
+ set:
+ gateway.hpa.targetTokensPerSecond: 6M
+ asserts:
+ - lengthEqual:
+ path: spec.metrics
+ count: 3
+ - equal:
+ path: spec.metrics[2]
+ value:
+ type: Pods
+ pods:
+ metric:
+ name: litellm_tokens_per_second
+ target:
+ type: AverageValue
+ averageValue: "6M"
+ - notContains:
+ path: spec.metrics
+ content:
+ type: Pods
+ pods:
+ metric:
+ name: litellm_requests_per_second
+ any: true
+
+ - it: renders requests and tokens targets together and keeps CPU and memory
+ template: gateway/hpa.yaml
+ set:
+ gateway.hpa.targetRequestsPerSecond: 90
+ gateway.hpa.targetTokensPerSecond: 6000000
+ asserts:
+ - lengthEqual:
+ path: spec.metrics
+ count: 4
+ - equal:
+ path: spec.metrics[0].resource.name
+ value: cpu
+ - equal:
+ path: spec.metrics[1].resource.name
+ value: memory
+ - equal:
+ path: spec.metrics[2].pods.metric.name
+ value: litellm_requests_per_second
+ - equal:
+ path: spec.metrics[3].pods.metric.name
+ value: litellm_tokens_per_second
+ - equal:
+ path: spec.metrics[3].pods.target.averageValue
+ value: "6000000"
+
+ - it: scales on workload metrics alone when the resource targets are cleared
+ template: gateway/hpa.yaml
+ set:
+ gateway.hpa.targetCPUUtilizationPercentage: null
+ gateway.hpa.targetMemoryUtilizationPercentage: null
+ gateway.hpa.targetRequestsPerSecond: 90
+ gateway.hpa.targetTokensPerSecond: 6000000
+ asserts:
+ - lengthEqual:
+ path: spec.metrics
+ count: 2
+ - notContains:
+ path: spec.metrics
+ content:
+ type: Resource
+ any: true
+ - equal:
+ path: spec.metrics[0].pods.metric.name
+ value: litellm_requests_per_second
+ - equal:
+ path: spec.metrics[1].pods.metric.name
+ value: litellm_tokens_per_second
+ - notMatchRegexRaw:
+ pattern: per_minute
+
+ - it: ignores the per-minute keys, which the chart never shipped
+ template: gateway/hpa.yaml
+ set:
+ gateway.hpa.targetRequestsPerMinute: 5400
+ gateway.hpa.targetTokensPerMinute: 360000000
+ asserts:
+ - lengthEqual:
+ path: spec.metrics
+ count: 2
+ - notContains:
+ path: spec.metrics
+ content:
+ type: Pods
+ any: true
+
+ - it: renders no ServiceMonitor by default
+ template: gateway/servicemonitor.yaml
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: refuses a ServiceMonitor without the metrics server, whose http port needs a bearer token
+ template: gateway/servicemonitor.yaml
+ set:
+ gateway.serviceMonitor.enabled: true
+ asserts:
+ - failedTemplate:
+ errorPattern: gateway.serviceMonitor.enabled requires gateway.metricsServer.enabled
+
+ - it: scrapes each gateway pod through the metrics port
+ template: gateway/servicemonitor.yaml
+ release:
+ name: rel
+ namespace: llm
+ set:
+ gateway.serviceMonitor.enabled: true
+ gateway.metricsServer.enabled: true
+ gateway.serviceMonitor.labels:
+ release: kube-prometheus-stack
+ asserts:
+ - isKind:
+ of: ServiceMonitor
+ - equal:
+ path: metadata.labels.release
+ value: kube-prometheus-stack
+ - equal:
+ path: spec.selector.matchLabels
+ value:
+ app.kubernetes.io/name: litellm
+ app.kubernetes.io/instance: rel
+ app.kubernetes.io/component: gateway
+ - equal:
+ path: spec.namespaceSelector.matchNames
+ value:
+ - llm
+ - equal:
+ path: spec.endpoints
+ value:
+ - port: metrics
+ path: /metrics/
+ interval: 15s
+ scrapeTimeout: 10s
+ scheme: http
+
+ - it: honours a custom scrape interval
+ template: gateway/servicemonitor.yaml
+ set:
+ gateway.serviceMonitor.enabled: true
+ gateway.serviceMonitor.interval: 30s
+ gateway.metricsServer.enabled: true
+ asserts:
+ - equal:
+ path: spec.endpoints[0].interval
+ value: 30s
diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml
index b5d535c992d..1873219d1ea 100644
--- a/helm/litellm/values.yaml
+++ b/helm/litellm/values.yaml
@@ -225,6 +225,26 @@ database:
usernameKey: username
passwordKey: password
+ # In-container connection pool (PgBouncer, transaction mode) shared by every
+ # gateway worker in the pod. Without it each of the `gateway.numWorkers`
+ # workers opens its own Prisma pool straight to Postgres, so a pod's
+ # footprint against the database's connection ceiling is
+ # numWorkers x connection_limit and grows with every replica. With it, the
+ # pod holds at most maxDbConnections upstream connections no matter how many
+ # workers run; the workers connect to the pool over loopback, with no extra
+ # network hop. The chart emits LITELLM_PGBOUNCER_ENABLED /
+ # LITELLM_PGBOUNCER_MAX_DB_CONNECTIONS / LITELLM_PGBOUNCER_MAX_CLIENT_CONN on
+ # the gateway container and its collector sidecar only: the backend runs a
+ # single worker and the migrations Job must keep a direct connection. With
+ # `database.writer.useIAMAuth` or `useAzureEntraAuth` the pool mints and
+ # renews the database token itself, so the workers never see it. Starting profile for
+ # `gateway.numWorkers: 4` is maxDbConnections: 20, so a database with a
+ # 5000-connection ceiling fits roughly 200 gateway replicas.
+ connectionPool:
+ enabled: false
+ maxDbConnections: 20
+ maxClientConn: 1000
+
# Optional Redis. Leave host empty to disable.
#
# This is the proxy's coordination store: cross-pod tpm/rpm rate limits, spend
@@ -284,6 +304,52 @@ gateway:
memory: 128Mi
limits:
memory: 512Mi
+ # Prometheus Operator ServiceMonitor for the gateway pods. Scrapes the
+ # `-metrics` Service, so it requires metricsServer above (the http
+ # port serves /metrics/ behind virtual-key auth). Every pod is its own scrape
+ # target, so the samples carry the `pod` label the per-pod autoscaling
+ # queries below group by.
+ serviceMonitor:
+ enabled: false
+ labels: {}
+ interval: 15s
+ scrapeTimeout: 10s
+ # Opt-in `collector` sidecar (same image, `python -m litellm.proxy.collector`)
+ # that runs the post-response spend pipeline (cost calculation, spend logs,
+ # spend counters, budget reservation reconciliation) so the uvicorn workers
+ # only serialise a compact event over loopback and go back to serving
+ # requests. It shares the pod's env, proxy config, in-container pgbouncer and
+ # Redis spend buffer, so the per-pod DB connection budget is unchanged.
+ # Delivery is at-most-once inside the pod: events already handed over are
+ # lost if the sidecar dies before writing them; events the workers cannot
+ # hand over follow `onUnavailable`.
+ collector:
+ enabled: false
+ # unix:////.sock (the becomes a shared emptyDir) or
+ # tcp://127.0.0.1:
+ address: unix:///var/run/litellm/collector.sock
+ # Events each uvicorn worker holds in memory while the sidecar is slow or
+ # restarting.
+ bufferSize: 1000
+ # fallback: run the pipeline in the worker when the sidecar is unreachable
+ # or the buffer is full (spend stays exact, that request costs gateway CPU
+ # again). drop: count and discard the event instead (spend under-reports).
+ onUnavailable: fallback
+ # How long the workers keep pushing buffered events on shutdown, and how
+ # long the sidecar keeps serving open connections after SIGTERM.
+ drainTimeoutSeconds: 10
+ # Sized independently of the gateway container; the pipeline is CPU bound.
+ resources:
+ requests:
+ cpu: 500m
+ memory: 1Gi
+ limits:
+ cpu: "1"
+ memory: 2Gi
+ # With hpa.targetCPUUtilizationPercentage set, scale on an autoscaling/v2
+ # ContainerResource metric of the `gateway` container only, so the
+ # sidecar's CPU never drives inference replicas. Needs Kubernetes 1.30+.
+ scaleOnGatewayContainerCpu: true
image:
repository: ghcr.io/berriai/litellm-gateway
tag: "" # defaults to .Chart.AppVersion
@@ -340,6 +406,25 @@ gateway:
# policies:
# - { type: Percent, value: 100, periodSeconds: 30 }
behavior: {}
+ # Opt-in per-pod workload targets, rendered as autoscaling/v2 `Pods` metrics
+ # named `litellm_requests_per_second` and `litellm_tokens_per_second` with an
+ # AverageValue target. They coexist with the CPU/memory targets above: the
+ # HPA scales on whichever metric asks for the most replicas. Kubernetes has
+ # no idea what a token is, so a Prometheus Adapter must serve those two
+ # names on custom.metrics.k8s.io from the proxy's counters, grouped by the
+ # scrape target's `pod` label (enable serviceMonitor above):
+ # litellm_requests_per_second:
+ # sum(rate(litellm_proxy_total_requests_metric_total{<<.LabelMatchers>>}[1m])) by (<<.GroupBy>>)
+ # litellm_tokens_per_second:
+ # sum(rate(litellm_total_tokens_metric_total{<<.LabelMatchers>>}[1m])) by (<<.GroupBy>>)
+ # rate() over [1m] is already per second, so no `* 60`. How fast the HPA
+ # reacts is set by that window, the scrape interval and the HPA sync period
+ # (15s by default), not by the unit: keep serviceMonitor.interval at 15s or
+ # faster so a 1m window holds at least 4 samples. averageValue takes SI
+ # suffixes, so "6M" is six million tokens per second per pod. Tokens are
+ # counted when a response completes, so TPS trails long streams.
+ targetRequestsPerSecond: ""
+ targetTokensPerSecond: ""
# PodDisruptionBudget for the gateway pods. Set exactly one of
# `minAvailable` / `maxUnavailable` (minAvailable wins if both are set;
# enabling without either falls back to `maxUnavailable: 1`). Disabled by
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260910000000_skills_on_object_permission/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260910000000_skills_on_object_permission/migration.sql
new file mode 100644
index 00000000000..c982bc38a69
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260910000000_skills_on_object_permission/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN IF NOT EXISTS "skills" TEXT[] DEFAULT ARRAY[]::TEXT[];
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index 05c5aad9303..817df082d8c 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -282,6 +282,7 @@ model LiteLLM_ObjectPermissionTable {
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
search_tools String[] @default([]) // search_tool_name values this key/team/user may call
mcp_tool_search_enabled Boolean?
+ skills String[] @default([]) // Claude Code plugin names granted to this key/team beyond the public (enabled) set
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]
verification_tokens LiteLLM_VerificationToken[]
diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml
index 91b4e4a7ba1..7d4c78088f1 100644
--- a/litellm-proxy-extras/pyproject.toml
+++ b/litellm-proxy-extras/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "litellm-proxy-extras"
-version = "0.4.95"
+version = "0.4.96"
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.95"
+version = "0.4.96"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-proxy-extras==",
diff --git a/litellm-rust/AGENTS.md b/litellm-rust/AGENTS.md
index b8d1f2db4d7..17856218e60 100644
--- a/litellm-rust/AGENTS.md
+++ b/litellm-rust/AGENTS.md
@@ -1,18 +1,19 @@
# AGENTS.md
-litellm-rust has five crates. A crate is a layer or shared foundation, not a route. Routes (ocr, realtime, chat) and providers (mistral, openai) are modules inside the layers.
+litellm-rust has six crates. A crate is a layer or shared foundation, not a route. Routes (ocr, realtime, chat) and providers (mistral, openai) are modules inside the layers.
## Crates
| Crate | Role |
|-------|------|
| litellm-core | The LiteLLM SDK in Rust. One public entrypoint per top-level call (`messages::messages()`), owning types, transforms, provider resolution, auth, and the provider HTTP call. Call it, get a typed response. |
+| litellm-token-counter | Standalone input token counting shared by host integrations without pulling in the full SDK. |
| litellm-config | Config-loading boundary. Returns resolved core deployment data and optionally delegates loading to Python. |
| litellm-ai-gateway | The axum server (behind the `server` feature) plus the WebSocket hosts. Translates HTTP/WS to core entrypoints; owns no provider logic and no handlers. |
| litellm-python-interop | Domain-neutral PyO3 foundation for GIL handling and typed Python/Serde conversion. |
| litellm-python-bridge | PyO3 cdylib exposing LiteLLM Rust APIs to the Python SDK. Owns API registration, domain wiring, and Python exception mapping. |
-Dependency direction is acyclic: `litellm-config` depends on `litellm-core`, the gateway depends on both, and `litellm-python-bridge` depends on the domain layers and `litellm-python-interop`. The interop foundation depends on no LiteLLM domain crate.
+Dependency direction is acyclic: `litellm-config` depends on `litellm-core`, the gateway depends on both, and `litellm-python-bridge` depends on the domain layers, `litellm-token-counter`, and `litellm-python-interop`. The token counter and interop foundations depend on no LiteLLM domain crate.
## Where a route lives
diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock
index 43b9ec1aac2..0e8e6e09a21 100644
--- a/litellm-rust/Cargo.lock
+++ b/litellm-rust/Cargo.lock
@@ -2,6 +2,20 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "serde",
+ "version_check",
+ "zerocopy",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -418,7 +432,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-util",
"http 1.4.2",
@@ -468,6 +482,12 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -542,6 +562,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "cc"
version = "1.3.0"
@@ -644,6 +673,21 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+[[package]]
+name = "compact_str"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "serde",
+ "static_assertions",
+]
+
[[package]]
name = "const-oid"
version = "0.10.2"
@@ -696,7 +740,7 @@ dependencies = [
"ciborium",
"clap",
"criterion-plot",
- "itertools",
+ "itertools 0.13.0",
"num-traits",
"oorandom",
"page_size",
@@ -716,7 +760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [
"cast",
- "itertools",
+ "itertools 0.13.0",
]
[[package]]
@@ -778,6 +822,56 @@ dependencies = [
"cmov",
]
+[[package]]
+name = "daachorse"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5614204febbc33cc07a2806aa6440b904ac012b68eecc37f4493ea4a76455a3d"
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "dary_heap"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -790,6 +884,37 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+[[package]]
+name = "derive_builder"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+dependencies = [
+ "derive_builder_core",
+ "syn 2.0.119",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -841,6 +966,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+[[package]]
+name = "esaxx-rs"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
+
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -964,6 +1095,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -973,7 +1116,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
- "r-efi",
+ "r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1220,7 +1363,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
@@ -1319,6 +1462,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -1348,6 +1497,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
+ "serde",
+ "serde_core",
]
[[package]]
@@ -1365,6 +1516,15 @@ dependencies = [
"either",
]
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1409,7 +1569,7 @@ name = "litellm-ai-gateway"
version = "0.1.0"
dependencies = [
"axum",
- "base64",
+ "base64 0.22.1",
"futures-channel",
"futures-util",
"litellm-config",
@@ -1447,17 +1607,19 @@ dependencies = [
"aws-sigv4",
"aws-smithy-runtime-api",
"aws-types",
- "base64",
+ "base64 0.22.1",
"rand 0.8.7",
"reqwest",
"rstest",
"serde",
"serde_json",
+ "serde_path_to_error",
"sha2 0.10.9",
"thiserror 2.0.19",
"tokio",
"tracing",
"tracing-subscriber",
+ "url",
]
[[package]]
@@ -1469,6 +1631,7 @@ dependencies = [
"litellm-ai-gateway",
"litellm-core",
"litellm-python-interop",
+ "litellm-token-counter",
"pyo3",
"pyo3-async-runtimes",
"serde",
@@ -1489,6 +1652,22 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "litellm-token-counter"
+version = "0.1.0"
+dependencies = [
+ "criterion",
+ "indexmap",
+ "itoa",
+ "rand 0.8.7",
+ "rstest",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.19",
+ "tokenizers",
+ "unicode-normalization-alignments",
+]
+
[[package]]
name = "litemap"
version = "0.8.2"
@@ -1507,6 +1686,22 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+[[package]]
+name = "macro_rules_attribute"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c"
+dependencies = [
+ "macro_rules_attribute-proc_macro",
+ "pastey",
+]
+
+[[package]]
+name = "macro_rules_attribute-proc_macro"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c"
+
[[package]]
name = "matchit"
version = "0.7.3"
@@ -1535,6 +1730,12 @@ dependencies = [
"unicase",
]
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
[[package]]
name = "mio"
version = "1.2.2"
@@ -1546,6 +1747,38 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "monostate"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67"
+dependencies = [
+ "monostate-impl",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "monostate-impl"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -1576,6 +1809,28 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "onig"
+version = "6.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
+dependencies = [
+ "bitflags",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
[[package]]
name = "oorandom"
version = "11.1.5"
@@ -1604,6 +1859,18 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pastey"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1850,6 +2117,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -1863,10 +2136,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
- "rand_chacha",
+ "rand_chacha 0.3.1",
"rand_core 0.6.4",
]
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand"
version = "0.10.2"
@@ -1888,6 +2171,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -1897,6 +2190,15 @@ dependencies = [
"getrandom 0.2.17",
]
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -1922,6 +2224,17 @@ dependencies = [
"rayon-core",
]
+[[package]]
+name = "rayon-cond"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
+dependencies = [
+ "either",
+ "itertools 0.14.0",
+ "rayon",
+]
+
[[package]]
name = "rayon-core"
version = "1.13.0"
@@ -1979,7 +2292,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
@@ -2361,12 +2674,36 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "spm_precompiled"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
+dependencies = [
+ "base64 0.13.1",
+ "nom",
+ "serde",
+ "unicode-segmentation",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -2535,6 +2872,39 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+[[package]]
+name = "tokenizers"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7afbf6e88718afcc138bad01d6ccc3051dbbc3b2ce9793d8b8a3aeb610969cfc"
+dependencies = [
+ "ahash",
+ "compact_str",
+ "daachorse",
+ "dary_heap",
+ "derive_builder",
+ "esaxx-rs",
+ "getrandom 0.3.4",
+ "itertools 0.14.0",
+ "log",
+ "macro_rules_attribute",
+ "monostate",
+ "onig",
+ "paste",
+ "rand 0.9.5",
+ "rayon",
+ "rayon-cond",
+ "regex",
+ "regex-syntax",
+ "serde",
+ "serde_json",
+ "spm_precompiled",
+ "thiserror 2.0.19",
+ "unicode-normalization-alignments",
+ "unicode-segmentation",
+ "unicode_categories",
+]
+
[[package]]
name = "tokio"
version = "1.53.0"
@@ -2773,6 +3143,27 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-normalization-alignments"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de"
+dependencies = [
+ "smallvec",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2856,6 +3247,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
@@ -3081,6 +3481,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "writeable"
version = "0.6.3"
diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml
index 82de7f40069..f3e54e5b2aa 100644
--- a/litellm-rust/Cargo.toml
+++ b/litellm-rust/Cargo.toml
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/core",
+ "crates/token-counter",
"crates/config",
"crates/ai-gateway",
"crates/python-interop",
@@ -18,6 +19,7 @@ repository = "https://github.com/BerriAI/litellm"
tracing = "0.1"
tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] }
litellm-core = { path = "crates/core" }
+litellm-token-counter = { path = "crates/token-counter" }
litellm-config = { path = "crates/config" }
litellm-ai-gateway = { path = "crates/ai-gateway", default-features = false }
litellm-python-interop = { path = "crates/python-interop" }
@@ -39,6 +41,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net"]
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
base64 = "0.22"
+url = "2.5.8"
+criterion = "0.8.2"
[profile.release]
opt-level = 3
diff --git a/litellm-rust/crates/ai-gateway/README.md b/litellm-rust/crates/ai-gateway/README.md
index 9fef59a277d..cbcd8119546 100644
--- a/litellm-rust/crates/ai-gateway/README.md
+++ b/litellm-rust/crates/ai-gateway/README.md
@@ -6,17 +6,18 @@ dials OpenAI upstream, and splices the two sockets frame-by-frame.
## Crates
-`litellm-rust` has five crates. A crate is a layer or shared foundation, not a route:
+`litellm-rust` has six crates. A crate is a layer or shared foundation, not a route:
| Crate | Role |
|-------|------|
| litellm-core | The LiteLLM SDK in Rust — per-route entrypoints (`messages::messages()`) that resolve the provider, transform, and make the call; plus types, provider transforms, and the router. |
+| litellm-token-counter | Standalone input token counting shared by host integrations without pulling in the full SDK. |
| litellm-config | Config-loading boundary. Returns resolved deployments and optionally delegates loading to Python. |
| litellm-ai-gateway | The Axum server (behind the `server` feature) and WebSocket hosts. Translates HTTP/WS to core entrypoints; no provider handlers. |
| litellm-python-interop | Domain-neutral PyO3 foundation for GIL handling and typed Python/Serde conversion. |
| litellm-python-bridge | PyO3 cdylib exposing LiteLLM Rust APIs to the Python SDK. |
-Dependency direction is acyclic: config depends on core, the gateway depends on config and core, and the Python bridge depends on the domain layers and Python interop.
+Dependency direction is acyclic: config depends on core, the gateway depends on config and core, and the Python bridge depends on the domain layers, token counter, and Python interop.
- **Client endpoint:** `wss:///v1/realtime?model=` (WebSocket)
- **Auth:** `Authorization: Bearer $LITELLM_MASTER_KEY` (fails closed if unset)
diff --git a/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs b/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs
index dbe2d3a325b..098ce071efc 100644
--- a/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs
+++ b/litellm-rust/crates/ai-gateway/src/audio_transcription/hooks.rs
@@ -269,7 +269,7 @@ fn guardrail_error_to_core_error(error: GuardrailError) -> Error {
fn core_error_kind(error: &Error) -> &'static str {
match error {
- Error::Auth(_) => "AuthError",
+ Error::Auth(_) | Error::MissingApiKey { .. } => "AuthError",
Error::InvalidProvider(_) => "InvalidProvider",
Error::InvalidRequest(_) => "InvalidRequest",
Error::InvalidType { .. } => "InvalidType",
diff --git a/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs b/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs
index ed41f1ff9e7..d1dd811f7ab 100644
--- a/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs
+++ b/litellm-rust/crates/ai-gateway/src/ocr/hooks.rs
@@ -386,7 +386,7 @@ fn guardrail_error_to_core_error(error: GuardrailError) -> Error {
fn core_error_kind(error: &Error) -> &'static str {
match error {
- Error::Auth(_) => "AuthError",
+ Error::Auth(_) | Error::MissingApiKey { .. } => "AuthError",
Error::InvalidProvider(_) => "InvalidProvider",
Error::InvalidRequest(_) => "InvalidRequest",
Error::InvalidType { .. } => "InvalidType",
diff --git a/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs b/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs
index bb9f3851a77..c22d05f5726 100644
--- a/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs
+++ b/litellm-rust/crates/ai-gateway/src/routes/messages/mod.rs
@@ -114,7 +114,8 @@ impl IntoResponse for MessagesRouteError {
| Error::Connect(_)
| Error::InvalidResponse(_)
| Error::InvalidType { .. }
- | Error::MissingField(_) => (
+ | Error::MissingField(_)
+ | Error::MissingApiKey { .. } => (
StatusCode::BAD_GATEWAY,
"messages provider request failed".to_string(),
),
diff --git a/litellm-rust/crates/ai-gateway/src/trace_parity.rs b/litellm-rust/crates/ai-gateway/src/trace_parity.rs
index 614852c541d..21123df3f1c 100644
--- a/litellm-rust/crates/ai-gateway/src/trace_parity.rs
+++ b/litellm-rust/crates/ai-gateway/src/trace_parity.rs
@@ -47,10 +47,10 @@ pub async fn messages_request(
.header(CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.map_err(|error| Error::InvalidRequest(error.to_string()))?;
- let response = routes::app(state)
- .oneshot(request)
- .await
- .map_err(|error| match error {})?;
+ let response = match routes::app(state).oneshot(request).await {
+ Ok(response) => response,
+ Err(error) => match error {},
+ };
let status: StatusCode = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
diff --git a/litellm-rust/crates/core/Cargo.toml b/litellm-rust/crates/core/Cargo.toml
index c0de7ff3977..b4ca88cf16f 100644
--- a/litellm-rust/crates/core/Cargo.toml
+++ b/litellm-rust/crates/core/Cargo.toml
@@ -4,6 +4,11 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
+autotests = false
+
+[[test]]
+name = "workspace_crate_allowlist"
+path = "tests/workspace_crate_allowlist.rs"
[dependencies]
base64.workspace = true
@@ -11,10 +16,13 @@ rand.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
+serde_path_to_error = "0.1"
+tokio.workspace = true
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber = { workspace = true, optional = true }
sha2.workspace = true
+url.workspace = true
aws-config = { version = "1.9.0", default-features = false, features = ["rustls", "rt-tokio"], optional = true }
aws-credential-types = { version = "1.3.0", features = ["hardcoded-credentials"], optional = true }
aws-sdk-sts = { version = "1.108.0", default-features = false, features = ["rustls", "rt-tokio"], optional = true }
diff --git a/litellm-rust/crates/core/src/constants.rs b/litellm-rust/crates/core/src/constants.rs
index fc81f4fa029..108d2a48e30 100644
--- a/litellm-rust/crates/core/src/constants.rs
+++ b/litellm-rust/crates/core/src/constants.rs
@@ -43,3 +43,6 @@ pub const EMPTY_TEXT_PLACEHOLDER: &str =
"[System: Empty message content sanitised to satisfy protocol]";
pub const FUNCTION_TRACE_TARGET: &str = "litellm::function_trace";
+pub(crate) const OCR_HTTP_TIMEOUT_SECS: u64 = 600;
+pub(crate) const OCR_CONNECT_TIMEOUT_SECS: u64 = 10;
+pub(crate) const MISTRAL_OCR_API_BASE: &str = "https://api.mistral.ai/v1";
diff --git a/litellm-rust/crates/core/src/error.rs b/litellm-rust/crates/core/src/error.rs
index db3fa2ec704..0382314057f 100644
--- a/litellm-rust/crates/core/src/error.rs
+++ b/litellm-rust/crates/core/src/error.rs
@@ -17,6 +17,10 @@ pub enum Error {
InvalidRequest(String),
#[error("{0}")]
Auth(String),
+ #[error(
+ "Missing {provider} API Key - A call is being made to {provider} but no key is set either in the environment variables or via params"
+ )]
+ MissingApiKey { provider: &'static str },
#[error("upstream request failed with status {status}: {body}")]
Http { status: u16, body: String },
#[error("upstream network error: {0}")]
@@ -36,6 +40,59 @@ pub enum Error {
Unsupported(&'static str),
}
+#[derive(Clone, Debug, ThisError, PartialEq, Eq)]
+pub enum TransportError {
+ #[error("upstream request failed with status {status}: {body}")]
+ Http { status: u16, body: String },
+ #[error("upstream network error: {0}")]
+ Network(String),
+ #[error("could not reach the provider: {0}")]
+ Connect(String),
+}
+
+impl TransportError {
+ pub fn from_reqwest_before_dispatch(error: reqwest::Error) -> Self {
+ let before_dispatch = !error.is_timeout() && (error.is_connect() || error.is_builder());
+ let message = error.without_url().to_string();
+ if before_dispatch {
+ Self::Connect(message)
+ } else {
+ Self::Network(message)
+ }
+ }
+}
+
+impl From for TransportError {
+ fn from(error: reqwest::Error) -> Self {
+ Self::Network(error.without_url().to_string())
+ }
+}
+
+impl From for Error {
+ fn from(error: crate::ocr::error::OcrRequestError) -> Self {
+ match error {
+ crate::ocr::error::OcrRequestError::MissingField(field) => Self::MissingField(field),
+ error => Self::InvalidRequest(error.to_string()),
+ }
+ }
+}
+
+impl From for Error {
+ fn from(error: crate::ocr::error::OcrResponseError) -> Self {
+ Self::InvalidResponse(error.to_string())
+ }
+}
+
+impl From for Error {
+ fn from(error: TransportError) -> Self {
+ match error {
+ TransportError::Http { status, body } => Self::Http { status, body },
+ TransportError::Network(message) => Self::Network(message),
+ TransportError::Connect(message) => Self::Connect(message),
+ }
+ }
+}
+
pub fn json_type_name(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
@@ -46,3 +103,53 @@ pub fn json_type_name(value: &serde_json::Value) -> &'static str {
serde_json::Value::Object(_) => "object",
}
}
+
+#[cfg(test)]
+mod transport_tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn transport_errors_remove_urls_and_keep_dispatch_context() {
+ let error = reqwest::Client::builder()
+ .no_proxy()
+ .build()
+ .expect("client")
+ .get("http://localhost:invalid/private?api_key=secret")
+ .send()
+ .await
+ .expect_err("invalid port");
+ let error = TransportError::from_reqwest_before_dispatch(error);
+ assert!(matches!(error, TransportError::Connect(_)));
+ assert!(!error.to_string().contains("secret"));
+ assert!(!error.to_string().contains("private"));
+ }
+
+ #[tokio::test]
+ async fn request_timeout_is_not_safe_to_retry_as_a_connect_failure() {
+ use std::time::Duration;
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind");
+ let address = listener.local_addr().expect("address");
+ let request = reqwest::Client::builder()
+ .no_proxy()
+ .build()
+ .expect("client")
+ .get(format!("http://{address}"))
+ .timeout(Duration::from_millis(200))
+ .send();
+ let (response, accepted) = tokio::join!(
+ request,
+ tokio::time::timeout(Duration::from_secs(2), listener.accept())
+ );
+ let _connection = accepted
+ .expect("accept deadline")
+ .expect("accepted connection");
+ let error = response.expect_err("server does not respond");
+ assert!(error.is_timeout());
+ assert!(matches!(
+ TransportError::from_reqwest_before_dispatch(error),
+ TransportError::Network(_)
+ ));
+ }
+}
diff --git a/litellm-rust/crates/core/src/http_utils.rs b/litellm-rust/crates/core/src/http_utils.rs
index cb472dd5a57..9299bb77ac8 100644
--- a/litellm-rust/crates/core/src/http_utils.rs
+++ b/litellm-rust/crates/core/src/http_utils.rs
@@ -1,10 +1,43 @@
-//! Header and upstream-body helpers shared by every route module.
-
use serde_json::{Map, Value};
use crate::constants::UPSTREAM_ERROR_BODY_MAX_CHARS;
use crate::error::{Error, json_type_name};
+#[allow(
+ dead_code,
+ reason = "used by the OCR architecture in the next stacked PR"
+)]
+pub(crate) enum HeaderPolicy<'a> {
+ All,
+ Only(&'a [&'a str]),
+ Except(&'a [&'a str]),
+}
+
+#[allow(
+ dead_code,
+ reason = "used by the OCR architecture in the next stacked PR"
+)]
+pub(crate) fn with_headers(
+ builder: reqwest::RequestBuilder,
+ headers: &[(String, String)],
+ policy: HeaderPolicy<'_>,
+) -> reqwest::RequestBuilder {
+ headers
+ .iter()
+ .filter(|(name, _)| match policy {
+ HeaderPolicy::All => true,
+ HeaderPolicy::Only(names) => names
+ .iter()
+ .any(|allowed| name.eq_ignore_ascii_case(allowed)),
+ HeaderPolicy::Except(names) => !names
+ .iter()
+ .any(|excluded| name.eq_ignore_ascii_case(excluded)),
+ })
+ .fold(builder, |builder, (name, value)| {
+ builder.header(name, value)
+ })
+}
+
#[tracing::instrument(target = "litellm::function_trace", level = "trace", skip_all)]
pub async fn http_request(
request: reqwest::RequestBuilder,
@@ -12,8 +45,6 @@ pub async fn http_request(
request.send().await
}
-/// Bound an upstream error body before it crosses a host boundary, so provider
-/// bodies stay data-minimized.
pub fn truncate_error_body(body: &str) -> String {
if body.chars().count() <= UPSTREAM_ERROR_BODY_MAX_CHARS {
return body.to_string();
@@ -61,11 +92,77 @@ pub fn has_bearer_auth(headers: &[(String, String)]) -> bool {
})
}
+#[allow(
+ dead_code,
+ reason = "used by the OCR architecture in the next stacked PR"
+)]
+pub(crate) fn deserialize_optional_param<'de, D, T>(
+ deserializer: D,
+) -> Result