Compare commits

..

No commits in common. "main" and "v1.110.0-dev.2" have entirely different histories.

7804 changed files with 91588 additions and 328733 deletions

View file

@ -0,0 +1,73 @@
---
name: database-access
description: Organize database repositories and proxy dependency wiring, manage connection ownership, or port Prisma operations to Rust and SQLx without expanding migration scope
metadata:
internal: true
---
# Database access
## Situations
- Adding a query or an operation to a repository, read [adding a query](references/python-repository.md#adding-a-query)
- Moving existing Prisma calls behind a repository, read [moving existing access](references/python-repository.md#moving-existing-access-into-a-repository)
- Porting a repository from Prisma to Rust and SQLx, read [porting to SQLx](references/python-repository.md#porting-a-repository-to-sqlx)
- Injecting a repository into a route, read [Python and FastAPI](references/python-fastapi.md) or [Rust and Axum](references/rust-axum.md)
## Adding new database access
- Put Python database access behind an operation in `litellm/repositories`
- Give each repository its own FastAPI provider, so a route builds only the repositories it uses
- A provider borrows existing handles and performs no database I/O during construction
- Define the operations a route calls as a Protocol in `litellm/models/<feature>.py`, next to its domain types
- The Protocol takes and returns domain types, never Prisma models, raw rows or serialized payloads
- Parsing and serialization of stored values live in the repository
- Add a Protocol when a repository gets a provider, not up front for every repository
- Keep query execution inside the repository, with one implementation per repository and no backend switch or fallback
- A repository is either all Prisma or all native, never a mix of both
- Prisma is fine for the implementation, adding a query does not require a Rust port
- Native Rust callers use `litellm-rust/crates/db` directly
## Framework wiring
- Python routes receive a per-repository dependency such as `UserBannerRepositoryDep` and call its operations, such as `banners.get_banner()`
- `DatabaseDep` lives in `litellm/proxy/deps/database.py`, repository providers in `litellm/proxy/deps/repositories.py`
- A provider returns the feature's Protocol, so routes never see which backend implements it
- Dependencies used by one feature only, like the banner audit binding, stay next to that feature's routes
- Repositories never import FastAPI, so jobs and helpers can use them without a request
- Use named FastAPI providers with `Annotated[..., Depends(...)]`, ordinary helpers and jobs receive explicit arguments
- Preserve each route's no-database behavior, authorization precedence and background-task scheduling
- Read [Python and FastAPI](references/python-fastapi.md) for provider examples, lifespan state and route verification
- Read [Rust and Axum](references/rust-axum.md) when adding database access to Rust handlers
## Connection ownership
- Keep connection setup and shutdown separate from repositories, which own no pools
- Reuse existing handles, never connect per request or disconnect from a repository operation
- Give each pool one owner for setup and shutdown, borrowed handles never close it
- Finish requests, jobs and queued database work before closing connections
- Clean up resources opened before a startup failure, without a second disconnect fallback
- Add a backend to the existing lifecycle owner and pass its handle only to repositories that need it
- Preserve cancellation, timeout, error and transaction semantics across the native bridge
## Changing ownership or reloading resources
- Inventory initialization, reload, tests, background jobs and shutdown together
- Move every writer when changing authoritative storage, copying a replaceable global into state is insufficient
- A frozen repository does not make its clients immutable
- Decide whether each request captures one resource generation or reads it again during processing
- Build and validate replacements before publishing them, preserving the old generation on failure
- Serialize reloads and coordinate publication with shutdown
- Drain streams and background users before retiring old resources, retaining every generation grows resource use
- Define multi-worker coordination separately from an in-process swap
- Choose mechanisms such as `ArcSwap` only after defining publication, capture and retirement
## Migration context and boundaries
- Long-term direction is to centralize Python database access in repositories, then replace their Prisma implementations with Rust APIs one repository at a time
- Prisma and native repositories coexist during the transition, unrelated existing calls can stay as they are
- Prisma and SQLx keep separate client pools to PgBouncer, which pools connections to PostgreSQL
- Size both layers together and preserve reader/writer routing and pooling-mode compatibility
- PgBouncer does not share transactions between Prisma and SQLx, keep each atomic unit of work on one backend and transaction
- Never retry a failed Rust write through Prisma when it may already have committed
- Prisma owns schema migrations today, eventual SQLx migration ownership and Prisma removal are separate planned steps, not part of a query port

View file

@ -0,0 +1,101 @@
# Python and FastAPI
## Existing pattern
- `UserBannerStore` in `litellm/models/user_banner.py` is the Protocol the banner routes call
- `get_database` in `litellm/proxy/deps/database.py` reads the lifespan-owned handle from request state
- `get_user_banner_repository` in `litellm/proxy/deps/repositories.py` builds the banner repository from that handle
- Choose each repository's replica routing once in its provider, never at call sites
- A handler receives `UserBannerStore | None`, never a Prisma client or a concrete repository
- `UserBannerRepository` implements `UserBannerStore` structurally, keeping the existing Prisma table, serialization and configuration-sync behavior
- The banner audit provider binds the legacy audit callable at request time, without performing audit I/O
- A native repository can later implement the same Protocol without changing handler signatures, see [porting a repository to SQLx](python-repository.md#porting-a-repository-to-sqlx)
## Dependency injection
- Add one provider and one `Annotated` alias per repository in `litellm/proxy/deps/repositories.py`
- Annotate the provider and alias with the feature's Protocol, not the concrete repository
- A route depends only on the repositories it calls, so a request never builds repositories it does not use
- Do not build repositories once at lifespan, a reconnect can replace the Prisma client they wrap
```python
class UserBannerStore(Protocol):
async def get_banner(self) -> UserBanner: ...
async def save_banner(self, banner: UserBanner) -> None: ...
```
```python
async def get_user_banner_repository(database: DatabaseDep) -> UserBannerStore | None:
return None if database is None else UserBannerRepository(database.prisma)
UserBannerRepositoryDep: TypeAlias = Annotated[UserBannerStore | None, Depends(get_user_banner_repository)]
```
- FastAPI caches each provider within a request, so routes and sub-dependencies share one instance
- Ordinary helpers and jobs receive explicit arguments, never unresolved `Depends` defaults
- A route still reading the `prisma_client` global or building a repository inline switches to the repository's `...RepositoryDep`, keeping its no-database behavior and error precedence
```python
async def get_user_banner(banners: UserBannerRepositoryDep) -> UserBanner:
if banners is None:
return UserBanner()
return await banners.get_banner()
```
## Lifespan state
- Prisma startup owns connection setup, authentication refresh, health checks and migrations
- `Database` holds the initialized Prisma client and the `NativeDatabase` handle, without holding fields per feature
- The native handle opens no pool yet, its `connect` and `close` are noops
- The proxy lifespan builds the handle with `Database.open`, registers `Database.aclose` on its exit stack and publishes it in request state
- Callers only open and close `Database`, which closes Prisma and the native handle together, and `open` closes Prisma itself when the native connect fails
- Starlette shallow-copies lifespan state into request state, which is distinct from `app.state`
- The database module never imports a repository, legacy globals borrow its handle without owning shutdown
- Drain requests, jobs, logging, spend and audit work before database teardown
- The exit stack closes the handle on exit, including startup failures after the client is created
- When adding native resources, initialize them in the serving process, respecting runtime fork restrictions
- Await native shutdown instead of depending on Python garbage collection
- Apply the [connection ownership rules](../SKILL.md#connection-ownership) when changing the owner
## Timing and errors
- FastAPI resolves dependencies before entering the handler
- Handler exception handling cannot catch failures raised during dependency resolution
- A universal required-database dependency can override authorization and validation behavior
- Banner GET without a database returns the default disabled banner
- Banner PATCH checks the admin role before returning the existing missing-database error
- Invalid stored banner data falls back to the default, but storage failures still propagate
- Banner writes finish before the success audit is queued as a FastAPI background task
- Queue post-response work with `BackgroundTasks`, not bare `asyncio.create_task`, so the in-flight drain waits for it before the database closes
- Do not move background work into an awaited provider merely to remove an import
## Verification
- Exercise real FastAPI handlers using `dependency_overrides` for external boundaries
- Keep production parsing and persistence code real
- Restore overrides after each test
- Verify complete response bodies, status codes and OpenAPI against the base
- Include combined failure conditions to pin error precedence
- Check saved values, fresh revisions, audit contents and failed-write behavior
- Check lifespan state with a running lifespan, not only a TestClient that skips startup
- Verify providers do not connect, query, disconnect or audit eagerly
- Use real PostgreSQL and live proxy requests when changing persistence behavior
## Scope
- Preserve direct Python call sites or migrate them explicitly
- Inventory imports, state writers, side effects and error ordering before migrating a feature
- Preserve configuration sync and serialization, and state which legacy reads remain
- Do not migrate every route in a large module just to establish the pattern
- Do not invent providers for resources without consumers
## Framework references
- [Lifespan events](https://fastapi.tiangolo.com/advanced/events/) and [Starlette lifespan state](https://starlette.dev/lifespan/)
- [`SessionDep`-style `Annotated` aliases](https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-session-dependency), which `DatabaseDep` and `UserBannerRepositoryDep` follow
- [Dependency composition and caching](https://fastapi.tiangolo.com/tutorial/dependencies/sub-dependencies/)
- [Background tasks and dependencies with `yield`](https://fastapi.tiangolo.com/advanced/advanced-dependencies/#background-tasks-and-dependencies-with-yield-technical-details)
- [Dependency overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/)
- [Testing lifespan](https://fastapi.tiangolo.com/advanced/testing-events/)

View file

@ -0,0 +1,119 @@
# Python repositories
## Adding a query
Use this when a feature needs a read or write that no repository offers yet, including one more operation on an existing repository
### Before starting
- Search `litellm/repositories` for a repository that already owns the table, and extend it instead of adding a second one
- Check `litellm/models/<feature>.py` for an existing Protocol and domain types
- Decide the route's behavior when no database is configured, and where that check sits relative to authorization and validation
### Steps
1. Domain types and Protocol in `litellm/models/<feature>.py`
- The Protocol takes and returns domain types, never Prisma models, raw rows or serialized payloads
- Add the Protocol together with the provider, not for repositories nothing injects
2. Repository in `litellm/repositories/<feature>_repository.py`
- Implement the operation with Prisma, a Rust port is not required
- Compose the Prisma client and type the tables it touches with `TableActions` from `prisma_protocols.py`, never inherit `PrismaTableRepository`
- Parse and serialize stored values here, so callers only see domain types
- Never import FastAPI, connect, disconnect or choose replicas per call
3. Provider and `Annotated` alias in `litellm/proxy/deps/repositories.py`, see [dependency injection](python-fastapi.md#dependency-injection)
4. The route takes `<Feature>RepositoryDep`, handles `None` with the behavior decided above, and calls the operation
5. Jobs and helpers that need the operation receive the repository as an explicit argument, built by their caller from the handle it already holds
### Done when
- Handler tests drive the real route with `dependency_overrides` and the real repository code, see [verification](python-fastapi.md#verification)
- Tests cover the no-database path, error precedence, and what was actually stored
- Persistence changes are checked against real PostgreSQL through a live proxy
### Out of scope
- Porting the query to Rust, see [porting a repository to SQLx](#porting-a-repository-to-sqlx)
- Moving unrelated direct Prisma calls in the same module
## Moving existing access into a repository
Use this when code calls Prisma directly, through `prisma_client.db.<table>` or a helper, and the calls should live behind a repository. The backend stays Prisma
### Before starting
- List every call being moved, its callers, and the transaction or batch it runs in
- List the module's imports, global state writers, side effects such as audit and config sync, and error ordering
- Record current return values, raised errors and response bodies, these are what the change must preserve
### Steps
1. Move the existing Prisma code into the relevant repository in `litellm/repositories` without rewriting the queries
2. Add domain types and a Protocol in `litellm/models/<feature>.py` when a route will inject the repository
3. Replace each direct call with the repository operation, through a provider for routes and explicit arguments for jobs and helpers, see [Python and FastAPI](python-fastapi.md)
4. Calls sharing a transaction move into the same repository or unit of work in one change
5. State which legacy reads remain and why, when the change does not move all of them
### Done when
- Focused regression tests pin the preserved return values, errors and transaction behavior
- Response bodies, status codes and OpenAPI match the base
- Configuration sync and serialization behave the same, including invalid stored data
### Out of scope
- Switching the backend, even when an equivalent Rust query exists
- Converting other routes in the module just to establish the pattern
- Cleanups unrelated to the moved calls
## Porting a repository to SQLx
Status: TBD. No repository has been ported yet. `database.native` is a `NativeDatabase` from `crates/python-bridge/src/db.rs` whose `connect` and `close` are noops that open no pool, and the PyO3 repository surface does not exist. A real pool takes typed writer/reader settings and a token provider, never the Prisma `DATABASE_URL`, which carries Prisma-only params and rotating IAM tokens. The constraints below are settled, the steps are a sketch to revise after the first port lands
Use this only when a port is explicitly requested. Repository extraction does not require one
### When not to port
- An equivalent Rust or SQLx query existing is not a reason to switch a Prisma caller
- Leave existing backend selection unchanged unless integration is explicitly requested
- Never port part of a repository, it is either all Prisma or all native
### Before starting
- Identify the repository's inputs, outputs, errors and transaction boundary
- List repositories that share a transaction with it, they move together
- Check `litellm-rust/crates/db` for an equivalent query and reuse it when its behavior matches
### Steps
1. Rust queries, following `litellm-rust/crates/db/AGENTS.md`
- Static SQL in `queries/`, compile-checked with SQLx, typed inputs and row projections in `src/`
- Dynamic filters and sort keys become closed Rust enums that select static SQL, every value is bound
- Refresh SQLx query metadata after SQL changes
2. PyO3 surface exposing typed operations, never SQL text, table names or column names from Python. TBD: where it lives and how the handle reaches it
3. Native repository implementing the same Protocol in `litellm/repositories`
- Converts typed PyO3 results to domain types, preserving defaults, invalid-data handling and storage errors
- Python conversion stays in the bridge, not in `crates/db`
4. Switch the provider in `litellm/proxy/deps/repositories.py`, the Protocol and routes stay unchanged
```diff
async def get_user_banner_repository(database: DatabaseDep) -> UserBannerStore | None:
- return None if database is None else UserBannerRepository(database.prisma)
+ return None if database is None else NativeUserBannerRepository(database.native)
```
5. Delete the Prisma repository in the same change and switch every caller, including jobs and helpers
### Done when
- Behavior is verified against real PostgreSQL, covering null handling, conflicts, ordering, errors and transaction semantics
- `DATABASE_URL=... cargo test -p litellm-db --locked -- --include-ignored` passes against a Prisma-migrated database
- Live proxy requests show unchanged responses. TBD: a Python-side real-PostgreSQL test harness
### Rollback
- Roll back the release, never retry an uncertain native write through Prisma
### Out of scope
- SQLx owning schema migrations, and removing Prisma
- Converting unrelated routes to dependency injection

View file

@ -0,0 +1,4 @@
# Rust and Axum
- This should use same `crates/db` what is exposed to Python world.
- Details are TBD.

7
.circleci/AGENTS.md Normal file
View file

@ -0,0 +1,7 @@
# .circleci
The `integration` workflow on `main` runs from a scheduled pipeline in the CircleCI project settings, cron `9 0-8,14-23 * * *` (UTC) on `main` with no pipeline parameters. It has no `triggers:` block in this file because a workflow with one runs only on its schedule, so the API pipelines that set `run_migration_tests` or `routing_parity_base` would stop running it. If main stops getting integration runs, check that the schedule still exists and recreate it with these values
Jobs clone with the `checkout_over_https` command, never the built-in `checkout` step, because CircleCI falls back to the project's SSH checkout key without its GitHub App access, GitHub rejects that key, and the public repo needs no key. It fetches the job's commit with full history and no file contents (`--filter=blob:none`), like the built-in step did, so `scripts/path_filter.sh` can find a merge base
A step that checks out files from another commit fetches that commit with `--no-filter`, as `routing_parity` does, because the clone has no file contents and git would otherwise download each missing file in its own request, which took 210 to 896 seconds for the routing parity base

1780
.circleci/config.yml Normal file

File diff suppressed because it is too large Load diff

View file

@ -14,22 +14,22 @@ outside_cost_map_set=false
while IFS= read -r file || [ -n "$file" ]; do while IFS= read -r file || [ -n "$file" ]; do
[ -n "$file" ] || continue [ -n "$file" ] || continue
case "$file" in case "$file" in
litellm/_redis.py | litellm/_redis_credential_provider.py | litellm/caching/redis_cache.py | litellm/caching/evicted_client_closer.py | tests/unit/test_redis.py | tests/integration/sdk/test_redis_cluster_iam_auth.py | tests/unit/caching/test_redis_connection_pool.py | tests/unit/caching/test_redis_cluster_cache.py | tests/unit/caching/test_evicted_client_closer.py | .github/workflows/test-unit.yml | .github/actions/detect-changes/* | .github/scripts/detect_changes.sh | .github/scripts/ci/classify_changes.sh | tests/integration/run.py | pyproject.toml | uv.lock) litellm/_redis.py | litellm/_redis_credential_provider.py | litellm/caching/redis_cache.py | litellm/caching/evicted_client_closer.py | tests/unit/test_redis.py | tests/integration/sdk/test_redis_cluster_iam_auth.py | tests/unit/caching/test_redis_connection_pool.py | tests/unit/caching/test_redis_cluster_cache.py | tests/unit/caching/test_evicted_client_closer.py | .github/workflows/test-unit.yml | .github/actions/detect-changes/* | .github/scripts/detect_changes.sh | .circleci/scripts/classify_changes.sh | tests/integration/run.py | pyproject.toml | uv.lock)
has_redis_compat=true ;; has_redis_compat=true ;;
esac esac
case "$file" in case "$file" in
*.md | *.mdx) : ;; *.md | *.mdx) : ;;
pyproject.toml | */pyproject.toml | uv.lock | uv.toml | .python-version | litellm/rust/* | litellm/__init__.py | litellm/proxy/proxy_server.py | litellm/*mcp* | tests/*mcp* | litellm/integrations/arize/* | tests/base_sdk_tests/* | litellm/scripts/check_mcp_sdk_install.py | .github/workflows/test-mcp-dependency-resolution.yml | .github/actions/detect-changes/* | .github/actions/setup-uv-with-retries/* | .github/actions/cache-cargo-build/* | .github/scripts/detect_changes.sh | .github/scripts/uv_sync_with_retries.sh | .github/scripts/ci/classify_changes.sh | tests/unit/test_ci_path_filter.py | tests/unit/test_detect_changes.py) pyproject.toml | */pyproject.toml | uv.lock | uv.toml | .python-version | rust-toolchain.toml | litellm-rust/* | litellm/__init__.py | litellm/proxy/proxy_server.py | litellm/*mcp* | tests/*mcp* | litellm/integrations/arize/* | tests/base_sdk_tests/* | scripts/check_mcp_sdk_install.py | .github/workflows/test-mcp-dependency-resolution.yml | .github/actions/detect-changes/* | .github/actions/setup-uv-with-retries/* | .github/actions/cache-cargo-build/* | .github/scripts/detect_changes.sh | .github/scripts/uv_sync_with_retries.sh | .circleci/scripts/classify_changes.sh | tests/unit/test_circleci_path_filter.py | tests/unit/test_detect_changes.py)
has_mcp_dependencies=true ;; has_mcp_dependencies=true ;;
esac esac
case "$file" in case "$file" in
litellm/rust/* | litellm/rust_bridge/* | pyproject.toml | uv.lock | tests/windows_tests/* | .github/scripts/ci/* | .github/actions/*) litellm-rust/* | litellm/rust_bridge/* | rust-toolchain.toml | pyproject.toml | uv.lock | tests/windows_tests/* | .circleci/*)
has_windows_release=true ;; has_windows_release=true ;;
esac esac
case "$file" in case "$file" in
litellm/ui/* | tests/e2e/ui/*) has_client=true ;; ui/* | tests/e2e/ui/*) has_client=true ;;
*.md | *.mdx) : ;; docs/* | *.md | *.mdx) : ;;
.github/*) has_ci=true; has_backend=true ;; .github/* | .circleci/*) has_ci=true; has_backend=true ;;
*) has_backend=true ;; *) has_backend=true ;;
esac esac
case "$file" in case "$file" in

View file

@ -6,12 +6,10 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
run_full() { run_full() {
echo "path-filter[$category]: running job ($1)" echo "path-filter[$category]: running job ($1)"
echo "decision=run"
[ -z "${GITHUB_OUTPUT:-}" ] || echo "decision=run" >> "$GITHUB_OUTPUT"
exit 0 exit 0
} }
[ "${GITHUB_EVENT_NAME:-}" = pull_request ] || run_full "not a pull request" [ -n "${CIRCLE_PULL_REQUEST:-}" ] || run_full "not a pull request"
candidate_bases="${PATH_FILTER_BASE_BRANCH:-main}" candidate_bases="${PATH_FILTER_BASE_BRANCH:-main}"
merge_base="" merge_base=""
@ -38,6 +36,5 @@ if [ "$decision" = run ]; then
run_full "$category-relevant changes detected" run_full "$category-relevant changes detected"
fi fi
echo "path-filter[$category]: only unrelated changes detected" echo "path-filter[$category]: only unrelated changes detected; halting job as successful"
echo "decision=skip" circleci-agent step halt
[ -z "${GITHUB_OUTPUT:-}" ] || echo "decision=skip" >> "$GITHUB_OUTPUT"

View file

@ -30,7 +30,7 @@ egress_cgroup=litellm-integration
cleanup() { cleanup() {
original_status=$? original_status=$?
trap - EXIT INT TERM trap - EXIT INT TERM
sudo .venv/bin/python .github/scripts/ci/stop_integration_processes.py \ sudo .venv/bin/python .circleci/scripts/stop_integration_processes.py \
"$integration_identity" "$(id -u)" "$proxy_pid" "$peer_pid" "$upstream_pid" \ "$integration_identity" "$(id -u)" "$proxy_pid" "$peer_pid" "$upstream_pid" \
> "$results/process-cleanup.txt" 2>&1 || original_status=1 > "$results/process-cleanup.txt" 2>&1 || original_status=1
for owned_pid in "$peer_pid" "$proxy_pid" "$upstream_pid"; do for owned_pid in "$peer_pid" "$proxy_pid" "$upstream_pid"; do
@ -81,10 +81,10 @@ export INTEGRATION_UPSTREAM_URL=http://127.0.0.1:8190
export INTEGRATION_MASTER_KEY="$LITELLM_MASTER_KEY" export INTEGRATION_MASTER_KEY="$LITELLM_MASTER_KEY"
export LITELLM_UI_PATH="$PWD/litellm/proxy/_experimental/out" export LITELLM_UI_PATH="$PWD/litellm/proxy/_experimental/out"
if [ "$suite" = browser ]; then if [ "$suite" = browser ]; then
export LITELLM_UI_PATH="$PWD/litellm/ui/litellm-dashboard/out" export LITELLM_UI_PATH="$PWD/ui/litellm-dashboard/out"
test -f "$LITELLM_UI_PATH/index.html" test -f "$LITELLM_UI_PATH/index.html"
fi fi
export INTEGRATION_SEED="$(.venv/bin/python -c 'import hashlib,os; print(int(hashlib.sha256(((os.environ.get("GITHUB_SHA") or "local") + (os.environ.get("GITHUB_RUN_ID") or "local")).encode()).hexdigest()[:8],16))')" export INTEGRATION_SEED="$(.venv/bin/python -c 'import hashlib,os; print(int(hashlib.sha256(((os.environ.get("CIRCLE_SHA1") or os.environ.get("GITHUB_SHA") or "local") + (os.environ.get("CIRCLE_WORKFLOW_ID") or os.environ.get("GITHUB_RUN_ID") or "local")).encode()).hexdigest()[:8],16))')"
export INTEGRATION_ORDER_SEED="$INTEGRATION_SEED" export INTEGRATION_ORDER_SEED="$INTEGRATION_SEED"
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma > "$results/prisma-generate.log" 2>&1 uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma > "$results/prisma-generate.log" 2>&1
@ -93,7 +93,7 @@ export INTEGRATION_PROXY_DATABASE_URL=""
export INTEGRATION_PROXY_READ_REPLICA_URL="" export INTEGRATION_PROXY_READ_REPLICA_URL=""
export INTEGRATION_ROUTING="" export INTEGRATION_ROUTING=""
if [ "$mode" = replica ] || [ "$mode" = parity ]; then if [ "$mode" = replica ] || [ "$mode" = parity ]; then
.venv/bin/python .github/scripts/ci/prepare_replica_roles.py > "$results/prepare-replica-roles.log" 2>&1 .venv/bin/python .circleci/scripts/prepare_replica_roles.py > "$results/prepare-replica-roles.log" 2>&1
export INTEGRATION_PROXY_DATABASE_URL="postgresql://litellm_writer:litellm-writer@127.0.0.1:5432/circle_test" export INTEGRATION_PROXY_DATABASE_URL="postgresql://litellm_writer:litellm-writer@127.0.0.1:5432/circle_test"
export INTEGRATION_PROXY_READ_REPLICA_URL="postgresql://litellm_reader:litellm-reader@127.0.0.1:5432/circle_test" export INTEGRATION_PROXY_READ_REPLICA_URL="postgresql://litellm_reader:litellm-reader@127.0.0.1:5432/circle_test"
fi fi
@ -202,7 +202,7 @@ start_proxy() {
if [ "$isolated_workers" = false ]; then if [ "$isolated_workers" = false ]; then
start_proxy 4000 proxy.log start_proxy 4000 proxy.log
proxy_pid="$launched_pid" proxy_pid="$launched_pid"
.venv/bin/python .github/scripts/ci/wait_integration_services.py .venv/bin/python .circleci/scripts/wait_integration_services.py
curl --noproxy '*' -sSf -X POST "$INTEGRATION_PROXY_URL/config/update" \ curl --noproxy '*' -sSf -X POST "$INTEGRATION_PROXY_URL/config/update" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
-d '{"router_settings": {"num_retries": 0}}' > "$results/seed-router-settings.json" -d '{"router_settings": {"num_retries": 0}}' > "$results/seed-router-settings.json"
@ -210,7 +210,7 @@ if [ "$isolated_workers" = false ]; then
export INTEGRATION_PEER_URL=http://127.0.0.1:4001 export INTEGRATION_PEER_URL=http://127.0.0.1:4001
start_proxy 4001 peer.log start_proxy 4001 peer.log
peer_pid="$launched_pid" peer_pid="$launched_pid"
.venv/bin/python .github/scripts/ci/wait_integration_services.py .venv/bin/python .circleci/scripts/wait_integration_services.py
fi fi
fi fi
@ -233,13 +233,14 @@ if [ "$suite" = browser ]; then
E2E_UI_BASE_URL="$E2E_UI_BASE_URL" E2E_UI_ARTIFACT_DIR="$E2E_UI_ARTIFACT_DIR" \ E2E_UI_BASE_URL="$E2E_UI_BASE_URL" E2E_UI_ARTIFACT_DIR="$E2E_UI_ARTIFACT_DIR" \
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" CI=true \ LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" CI=true \
node tests/e2e/ui/node_modules/@playwright/test/cli.js test --config tests/e2e/ui/integration.config.ts node tests/e2e/ui/node_modules/@playwright/test/cli.js test --config tests/e2e/ui/integration.config.ts
.venv/bin/python .github/scripts/ci/verify_integration_browser.py "$results/browser-results.json" .venv/bin/python .circleci/scripts/verify_integration_browser.py "$results/browser-results.json"
exit 0 exit 0
fi fi
shard_arguments=() shard_arguments=()
if [ -n "${INTEGRATION_SHARD_FILES:-}" ]; then if [ "${CIRCLE_NODE_TOTAL:-1}" -gt 1 ]; then
cp "$INTEGRATION_SHARD_FILES" "$results/node-files.txt" .venv/bin/python tests/integration/run.py "$suite" --list \
| circleci tests split --split-by=timings --timings-type=filename > "$results/node-files.txt"
shard_arguments=(--shard-files "$results/node-files.txt") shard_arguments=(--shard-files "$results/node-files.txt")
fi fi
@ -264,7 +265,7 @@ env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
"${license_env[@]}" \ "${license_env[@]}" \
INTEGRATION_SEED="$INTEGRATION_SEED" \ INTEGRATION_SEED="$INTEGRATION_SEED" \
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \ INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
INTEGRATION_CASE_SHARDS="${INTEGRATION_CASE_SHARDS:-1}" INTEGRATION_CASE_SHARD_INDEX="${INTEGRATION_CASE_SHARD_INDEX:-0}" \ INTEGRATION_CASE_SHARDS="${CIRCLE_NODE_TOTAL:-1}" INTEGRATION_CASE_SHARD_INDEX="${CIRCLE_NODE_INDEX:-0}" \
LITELLM_LOCAL_MODEL_COST_MAP=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \ LITELLM_LOCAL_MODEL_COST_MAP=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \
INTEGRATION_PROXY_DATABASE_URL="$INTEGRATION_PROXY_DATABASE_URL" \ INTEGRATION_PROXY_DATABASE_URL="$INTEGRATION_PROXY_DATABASE_URL" \
INTEGRATION_PROXY_READ_REPLICA_URL="$INTEGRATION_PROXY_READ_REPLICA_URL" \ INTEGRATION_PROXY_READ_REPLICA_URL="$INTEGRATION_PROXY_READ_REPLICA_URL" \

View file

@ -0,0 +1,116 @@
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Final
from xml.etree import ElementTree
SUITES: Final = {
"startup": (("test_startup.py",), 13),
"recovery": (("test_recovery.py",), 15),
"legacy": (("test_legacy.py", "test_pooling.py"), 11),
"upgrade": (("test_upgrade.py", "test_rolling_upgrade.py"), 5),
"shaped": (("test_shaped_database.py",), 1),
}
def successful_junit(path: Path, expected: int, exit_code: int) -> bool:
if exit_code != 0 or not path.is_file():
return False
try:
root: Final = ElementTree.parse(path).getroot()
except ElementTree.ParseError:
return False
cases: Final = tuple(root.iter("testcase"))
identities: Final = frozenset((case.get("classname"), case.get("name")) for case in cases)
return len(cases) == len(identities) == expected and all(
not any(case.find(tag) is not None for tag in ("failure", "error", "skipped")) for case in cases
)
def output(*command: str) -> str:
return subprocess.check_output(command, text=True, timeout=90).strip()
def record_image() -> None:
source: Final = output("git", "rev-parse", "HEAD")
image: Final = output("docker", "image", "inspect", "litellm-docker-database:ci", "--format", "{{.Id}}")
revision: Final = output(
"docker",
"image",
"inspect",
"litellm-docker-database:ci",
"--format",
'{{index .Config.Labels "org.opencontainers.image.revision"}}',
)
assert re.fullmatch(r"[0-9a-f]{40}", source), "Invalid source revision"
assert revision == source, "Candidate image revision differs from the tested source"
Path("migration-image.json").write_text(
json.dumps(
{
"source_sha": source,
"image_id": image,
"candidate_image": os.environ.get("MIGRATION_CANDIDATE_IMAGE", ""),
}
)
)
def main() -> int:
suite: Final = os.environ["MIGRATION_TEST_SUITE"]
files, expected = SUITES[suite]
metadata: Final = json.loads(Path("migration-image.json").read_text())
assert metadata["source_sha"] == output("git", "rev-parse", "HEAD"), "Image and test source revisions differ"
assert metadata["image_id"] == output(
"docker", "image", "inspect", os.environ["LITELLM_MIGRATION_TEST_IMAGE"], "--format", "{{.Id}}"
), "Loaded image differs from the build output"
assert metadata["candidate_image"] == os.environ.get("MIGRATION_CANDIDATE_IMAGE", ""), "Wrong release candidate"
destination: Final = Path(os.environ["MIGRATION_TEST_OUTPUT"])
junit: Final = destination / "junit" / "results.xml"
junit.parent.mkdir(parents=True, exist_ok=True)
result: Final = subprocess.run(
(
sys.executable,
"-m",
"pytest",
*(f"tests/e2e/migrations/{name}" for name in files),
"-vv",
"--tb=short",
"--durations=10",
f"--junitxml={junit}",
"-o",
"addopts=",
"--reruns=0",
),
check=False,
timeout=1200,
)
passed: Final = successful_junit(junit, expected, result.returncode)
(destination / "verdict.json").write_text(
json.dumps(
{
**metadata,
"suite": suite,
"baseline_image": os.environ.get("LITELLM_MIGRATION_BASELINE_IMAGE", ""),
"expected_cases": expected,
"passed": passed,
"pytest_exit_code": result.returncode,
"test_revision": metadata["source_sha"],
"workflow_id": os.environ.get("CIRCLE_WORKFLOW_ID", ""),
"job_number": os.environ.get("CIRCLE_BUILD_NUM", ""),
},
indent=2,
)
)
return 0 if passed else 1
if __name__ == "__main__":
if sys.argv[1:] == ["record-image"]:
record_image()
else:
raise SystemExit(main())

View file

@ -31,7 +31,7 @@ def main() -> None:
result: Final = json.loads(Path(sys.argv[1]).read_text()) result: Final = json.loads(Path(sys.argv[1]).read_text())
assert not result.get("errors"), result.get("errors") assert not result.get("errors"), result.get("errors")
expected: Final = json.loads( expected: Final = json.loads(
(Path(__file__).resolve().parents[3] / "tests/e2e/ui/tests/integrationCritical/expected.json").read_text() (Path(__file__).resolve().parents[2] / "tests/e2e/ui/tests/integrationCritical/expected.json").read_text()
) )
assert expected and result["stats"]["expected"] == len(expected) assert expected and result["stats"]["expected"] == len(expected)
assert all(result["stats"][name] == 0 for name in ("unexpected", "flaky", "skipped")) assert all(result["stats"][name] == 0 for name in ("unexpected", "flaky", "skipped"))
@ -41,7 +41,7 @@ def main() -> None:
suites: Final = TypeAdapter(list[BrowserSuite]).validate_python(result["suites"], strict=True) suites: Final = TypeAdapter(list[BrowserSuite]).validate_python(result["suites"], strict=True)
specs: Final = tuple(spec for suite in suites for spec in cases(suite)) specs: Final = tuple(spec for suite in suites for spec in cases(suite))
repository: Final = Path(__file__).resolve().parents[3] repository: Final = Path(__file__).resolve().parents[2]
report_root: Final = Path(result["config"]["rootDir"]) report_root: Final = Path(result["config"]["rootDir"])
assert report_root.is_absolute(), "Playwright rootDir must be explicit" assert report_root.is_absolute(), "Playwright rootDir must be explicit"
observed: Final = tuple( observed: Final = tuple(

View file

@ -0,0 +1,57 @@
{
"name": "Python 3.11",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/python:3.11-bookworm",
// https://github.com/devcontainers/images/tree/main/src/python
// https://mcr.microsoft.com/en-us/product/devcontainers/python/tags
// "build": {
// "dockerfile": "Dockerfile",
// "context": ".."
// },
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "lts"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
"settings": {},
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"GitHub.copilot",
"GitHub.copilot-chat",
"ms-python.autopep8"
]
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [4000],
"containerEnv": {
"LITELLM_LOG": "DEBUG"
},
// Use 'portsAttributes' to set default properties for specific forwarded ports.
// More info: https://containers.dev/implementors/json_reference/#port-attributes
"portsAttributes": {
"4000": {
"label": "LiteLLM Server",
"onAutoForward": "notify"
}
},
// More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "litellm",
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bash ./.devcontainer/post-create.sh"
}

View file

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -e
echo "[post-create] Installing uv"
curl -LsSf https://astral.sh/uv/0.10.9/install.sh | env UV_NO_MODIFY_PATH=1 sh
export PATH="$HOME/.local/bin:$PATH"
echo "[post-create] Installing Python dependencies (uv)"
uv sync --frozen --group proxy-dev --extra proxy
echo "[post-create] Generating Prisma client"
uv run --no-sync prisma generate
echo "[post-create] Installing npm dependencies"
cd ui/litellm-dashboard && npm ci
echo "[post-create] Done"

View file

@ -1,13 +1,13 @@
litellm/vscode-extension docs
litellm/proxy/discovery_endpoints/skill_scanner_adapter cookbook
.github/* .circleci
!.github/packaging/ .github
!.github/packaging/*.py
tests tests
.git .git
.devcontainer .devcontainer
*.tgz *.tgz
log.txt log.txt
docker/Dockerfile.*
# Claude Flow generated files (must be excluded from Docker build) # Claude Flow generated files (must be excluded from Docker build)
.claude/ .claude/
@ -49,16 +49,10 @@ build/
*.egg-info/ *.egg-info/
.DS_Store .DS_Store
**/node_modules **/node_modules
litellm/ui/litellm-dashboard/.next ui/litellm-dashboard/.next
litellm/ui/litellm-dashboard/out ui/litellm-dashboard/out
litellm/rust/target/ litellm-rust/target/
litellm/rust_bridge/_native*.so litellm/rust_bridge/_native*.so
*.log *.log
.env .env
.env.local .env.local
litellm/deploy/**
!litellm/deploy/docker/
litellm/deploy/docker/**
!litellm/deploy/docker/build_admin_ui.sh
!litellm/deploy/docker/docker-entrypoint.sh

32
.env.example Normal file
View file

@ -0,0 +1,32 @@
# OpenAI
OPENAI_API_KEY = ""
OPENAI_BASE_URL = ""
# Cohere
COHERE_API_KEY = ""
# OpenRouter
OR_SITE_URL = ""
OR_APP_NAME = "LiteLLM Example app"
OR_API_KEY = ""
# Azure API base URL
AZURE_API_BASE = ""
# Azure API version
AZURE_API_VERSION = ""
# Azure API key
AZURE_API_KEY = ""
# Replicate
REPLICATE_API_KEY = ""
REPLICATE_API_TOKEN = ""
# Anthropic
ANTHROPIC_API_KEY = ""
# Infisical
INFISICAL_TOKEN = ""
# Novita AI
NOVITA_API_KEY = ""
# INFINITY
INFINITY_API_KEY = ""
# Development Configs
# Generate one with: echo "LITELLM_MASTER_KEY=sk-$(openssl rand -hex 32)"
LITELLM_MASTER_KEY = ""
DATABASE_URL = "postgresql://llmproxy:dbpassword9090@db:5432/litellm"
STORE_MODEL_IN_DB = "True"

40
.git-blame-ignore-revs Normal file
View file

@ -0,0 +1,40 @@
# Add the commit hash of any commit you want to ignore in `git blame` here.
# One commit hash per line.
#
# The GitHub Blame UI will use this file automatically!
#
# Run this command to always ignore formatting commits in `git blame`
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# Update pydantic code to fix warnings (GH-3600)
876840e9957bc7e9f7d6a2b58c4d7c53dad16481
# style(ui): run prettier --write across the dashboard (#29622)
7edf3a9cb55548b143df1692f4ed7c4681d7fcf7
# style: reformat litellm/ with ruff format (#31317)
17bfd415aeb5a57fb646b5cc67da1c730aa7c50b
# style: unify ruff format width on 120 (#31518)
48b5a5a0cc5a694a11219416ee0b6eb6e620e74e
# refactor(imports): move collections.abc names out of typing (#35495)
397e8e4918777e4e60a7f5e88699e0a9a7dabb3d
# refactor(lint): apply every safe ruff autofix and zero 28 strict-rule budgets (#35495)
b604e2b20c6db2099085a2f0e59b7e99e87eed6f
# refactor(logging): drop redundant !s conversion flags from f-strings (#35546)
7b2d3440cba3160277470f7a0180098ae9b87864
# perf: build log messages lazily so filtered-out log records cost nothing (#35703)
c9887a1f94bc1e7e4bdfe64d640f0509a0bc19dd
# feat(lint): enforce Final on locals and freeze function parameters (#35807)
2708620d6a599cc73c1950a942d26ac26a7ed3d4
# chore(lint): remove litellm/types from the ruff lint exclusion (#35926)
4e32a8bf6a1e1af1e04b67c759841ccef44b2235
# chore(lint): strip inert type: ignore comments and zero LIT009/LIT010/LIT011 headroom (#35928)
338e411103ad5d7003e97f34f04fa36bca542dbe

2
.gitattributes vendored Normal file
View file

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

View file

@ -22,7 +22,7 @@ secret:
# Build outputs and static assets # Build outputs and static assets
- "litellm/proxy/_experimental/out/**" - "litellm/proxy/_experimental/out/**"
- "litellm/ui/litellm-dashboard/public/**" - "ui/litellm-dashboard/public/**"
- "**/swagger/*.js" - "**/swagger/*.js"
- "**/*.woff" - "**/*.woff"
- "**/*.woff2" - "**/*.woff2"
@ -34,6 +34,7 @@ secret:
- "tests/**/*.txt" - "tests/**/*.txt"
# Documentation and other non-code files # Documentation and other non-code files
- "docs/**"
- "**/*.md" - "**/*.md"
- "**/*.lock" - "**/*.lock"
- "uv.lock" - "uv.lock"

5
.github/AGENTS.md vendored
View file

@ -1,5 +0,0 @@
# CI
Integration groups and replica runs remain in `workflows/test-integration.yml`. Do not duplicate them in another workflow.
CI helpers used by existing workflows live in `scripts/ci/`. Jobs that need merge bases check out full history. Removing a CI job does not count its tests as covered. List tests that no remaining job invokes explicitly in `ci-coverage-allowlist.yml`.

6
.github/CODEOWNERS vendored
View file

@ -1,6 +1,6 @@
/model_prices_and_context_window.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri /model_prices_and_context_window.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
/litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri /litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
/litellm/scripts/ci_cd/cost_map_pins.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri /ci_cd/cost_map_pins.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
/litellm/deploy/helm/ @yassin-berriai /helm/ @yassin-berriai
/litellm/deploy/terraform/ @yassin-berriai /terraform/ @yassin-berriai
/.github/workflows/zizmor.yml @yuneng-berri /.github/workflows/zizmor.yml @yuneng-berri

View file

@ -3,7 +3,7 @@ description: >-
Cache the Cargo registry and target directory the root package's build needs, Cache the Cargo registry and target directory the root package's build needs,
so only the first job on a given Cargo.lock compiles the bridge from scratch. so only the first job on a given Cargo.lock compiles the bridge from scratch.
litellm builds through maturin, which compiles litellm/rust/crates/python-bridge litellm builds through maturin, which compiles litellm-rust/crates/python-bridge
in the dev profile for editable installs. `uv sync` therefore pays a full build in the dev profile for editable installs. `uv sync` therefore pays a full build
in every job that installs the workspace. Nothing caught it, because the uv cache in every job that installs the workspace. Nothing caught it, because the uv cache
holds wheels uv downloads rather than wheels it builds, and a path dependency holds wheels uv downloads rather than wheels it builds, and a path dependency
@ -31,8 +31,8 @@ runs:
path: | path: |
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
litellm/rust/target litellm-rust/target
key: ${{ runner.os }}-maturin-${{ inputs.profile }}-${{ hashFiles('litellm/rust/Cargo.lock') }} key: ${{ runner.os }}-maturin-${{ inputs.profile }}-${{ hashFiles('litellm-rust/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-maturin-${{ inputs.profile }}- ${{ runner.os }}-maturin-${{ inputs.profile }}-
@ -43,7 +43,7 @@ runs:
path: | path: |
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
litellm/rust/target litellm-rust/target
key: ${{ runner.os }}-maturin-${{ inputs.profile }}-${{ hashFiles('litellm/rust/Cargo.lock') }} key: ${{ runner.os }}-maturin-${{ inputs.profile }}-${{ hashFiles('litellm-rust/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-maturin-${{ inputs.profile }}- ${{ runner.os }}-maturin-${{ inputs.profile }}-

View file

@ -1,39 +0,0 @@
name: "Cache the Go build"
description: >-
Restore the Go module and build caches on every run and save them only from
main, so pull requests reuse main's cache instead of evicting it with their
own copies.
actions/setup-go's built-in cache saves from every ref, and a cache saved by a
pull request is readable only by that pull request. Callers must pass
`cache: false` to setup-go and run this action after it.
inputs:
dependency-path:
description: "Path to the go.sum that keys the cache"
required: true
runs:
using: composite
steps:
- name: Restore and save the Go module and build caches
if: github.ref == 'refs/heads/main'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles(inputs.dependency-path) }}
restore-keys: |
${{ runner.os }}-go-
- name: Restore the Go module and build caches
if: github.ref != 'refs/heads/main'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles(inputs.dependency-path) }}
restore-keys: |
${{ runner.os }}-go-

View file

@ -1,7 +1,7 @@
name: "Detect relevant changes" name: "Detect relevant changes"
description: >- description: >-
Classify the pull request's changed files with .github/scripts/ci/classify_changes.sh Classify the pull request's changed files with .circleci/scripts/classify_changes.sh
and expose decision=run|skip for one category. backend means anything outside litellm/ui/, and expose decision=run|skip for one category. backend means anything outside ui/,
docs/ and markdown; ui means the dashboard sources alone. decision=skip lets callers docs/ and markdown; ui means the dashboard sources alone. decision=skip lets callers
short-circuit expensive steps while the job still completes successfully and satisfies short-circuit expensive steps while the job still completes successfully and satisfies
its required status check, which a paths: filter cannot do because a workflow that its required status check, which a paths: filter cannot do because a workflow that

View file

@ -1,37 +0,0 @@
name: "Restore the Rust bridge"
description: >-
Restore the prebuilt Rust bridge that the unit workflow's rust-bridge job saves
on main, keyed on every input of the Rust build.
The bridge is abi3, so one build serves every supported Python version. A job
whose Rust inputs match main installs the restored wheel and native library
instead of compiling them inside `uv sync`. On a miss the caller builds from
source as before.
The restored `rust-bridge-dist` directory holds the editable litellm wheel, the
litellm-proxy gateway wheel, and `litellm/rust_bridge/_native.abi3.so`.
outputs:
key:
description: "Cache key for the bridge built from this checkout"
value: ${{ steps.key.outputs.key }}
cache-hit:
description: "'true' when the bridge for this exact key was restored"
value: ${{ steps.restore.outputs.cache-hit }}
runs:
using: composite
steps:
- name: Define the Rust bridge cache key
id: key
shell: bash
env:
RUST_BRIDGE_CACHE_KEY: ${{ runner.os }}-rust-bridge-editable-${{ hashFiles('litellm/rust/Cargo.lock', 'litellm/rust/Cargo.toml', 'litellm/rust/crates/**', 'model_prices_and_context_window.json', 'pyproject.toml', 'litellm/packaging/litellm-proxy/pyproject.toml') }}
run: echo "key=$RUST_BRIDGE_CACHE_KEY" >> "$GITHUB_OUTPUT"
- name: Restore the Rust bridge
id: restore
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: rust-bridge-dist
key: ${{ steps.key.outputs.key }}

View file

@ -11,11 +11,6 @@ inputs:
runs: runs:
using: composite using: composite
steps: steps:
- name: Configure the Rust build environment
shell: bash
working-directory: ${{ github.workspace }}
run: bash .github/scripts/setup_rust_environment.sh
- name: Set up uv (attempt 1) - name: Set up uv (attempt 1)
id: attempt-1 id: attempt-1
continue-on-error: true continue-on-error: true

View file

@ -4,16 +4,6 @@ description: >-
by a job nor listed here, so every entry below is a decision on the record. by a job nor listed here, so every entry below is a decision on the record.
test_paths: test_paths:
- reason: ran only in CircleCI, removed
paths:
- tests/e2e/migrations/test_legacy.py
- tests/e2e/migrations/test_pooling.py
- tests/e2e/migrations/test_recovery.py
- tests/e2e/migrations/test_rolling_upgrade.py
- tests/e2e/migrations/test_shaped_database.py
- tests/e2e/migrations/test_startup.py
- tests/e2e/migrations/test_upgrade.py
- tests/integration/database/test_roi_observed.py
- reason: >- - reason: >-
litellm.agent() end-to-end suite. It drives the real claude, codex, opencode and pi CLIs and litellm.agent() end-to-end suite. It drives the real claude, codex, opencode and pi CLIs and
deepagents against a live LiteLLM AI Gateway, so it needs those binaries on PATH plus deepagents against a live LiteLLM AI Gateway, so it needs those binaries on PATH plus
@ -29,7 +19,7 @@ test_paths:
- tests/rust-python-harness - tests/rust-python-harness
- reason: >- - reason: >-
Owned by the open pull request that moves the Rust/Python parity checks (#45135). It ran in Owned by the open pull request that moves the Rust/Python parity checks (#45135). It ran in
the retired build_and_test job that globbed tests/test_*.py, the CircleCI build_and_test job that globbed tests/test_*.py, which this migration deletes,
and that pull request gives it a new home and that pull request gives it a new home
paths: paths:
- tests/test_rust_python_harness.py - tests/test_rust_python_harness.py
@ -67,10 +57,19 @@ test_paths:
- tests/integration/test_oci_proxy_integration.py - tests/integration/test_oci_proxy_integration.py
dockerfiles: dockerfiles:
- reason: >-
The dashboard container is a static Next.js export served by nginx, and the dashboard build
and lint workflows already exercise that output, so building the image adds no signal about it
paths:
- ui/Dockerfile
- reason: >-
An example image under cookbook/ that is documentation rather than a shipped artifact
paths:
- cookbook/litellm-ollama-docker-image/Dockerfile
- reason: >- - reason: >-
The Rust gateway image compiles the whole workspace in release mode, which is too slow for The Rust gateway image compiles the whole workspace in release mode, which is too slow for
a per-pull-request job while the gateway binary is still being assembled; the Rust lint, a per-pull-request job while the gateway binary is still being assembled; the Rust lint,
clippy, and compile jobs already cover the code it packages. Revisit when the gateway is clippy, and compile jobs already cover the code it packages. Revisit when the gateway is
published published
paths: paths:
- litellm/rust/crates/gateway/Dockerfile - litellm-rust/crates/gateway/Dockerfile

72
.github/codecov.yml vendored
View file

@ -1,72 +0,0 @@
codecov:
require_ci_to_pass: false # post coverage status even if CI has unrelated failures
notify:
wait_for_ci: false # post as soon as expected uploads arrive, don't wait on CI
ignore:
- "litellm/rust/**"
# Flagged uploads avoid overlapping sessions on reruns.
# Only the flags below reuse parent coverage, avoiding stale line maps elsewhere.
flag_management:
default_rules:
carryforward: false
individual_flags:
- name: unit
carryforward: true
- name: core-packaging
carryforward: true
- name: mcp-integration
carryforward: true
component_management:
individual_components:
- component_id: "Router"
paths:
- "router"
- component_id: "LLMs"
paths:
- "*/llms/*"
- component_id: "Caching"
paths:
- "*/caching/*"
- ".*redis.*"
- component_id: "litellm_logging"
paths:
- "*/integrations/*"
- ".*litellm_logging.*"
- component_id: "Proxy_Authentication"
paths:
- "*/proxy/auth/**"
- component_id: "Enterprise"
paths:
- "litellm/enterprise/**"
- component_id: "Batches"
paths:
- "*/proxy/batches_endpoints/**"
- "litellm/batches/**"
- "*/llms/*/batches/**"
- component_id: "Videos"
paths:
- "litellm/videos/**"
- "*/proxy/video_endpoints/**"
- "*/llms/*/videos/**"
- component_id: "Realtime"
paths:
- "litellm/realtime_api/**"
- "*/proxy/realtime_endpoints/**"
- "*/llms/*/realtime/**"
- "litellm/litellm_core_utils/realtime_streaming.py"
comment:
layout: "header, diff, flags, components" # show component info in the PR comment
coverage:
status:
project:
default:
target: auto
threshold: 0% # do not allow project coverage to drop
patch:
default:
target: auto
threshold: 0% # patch coverage should be 100%

19
.github/observatory/litellm_config.yaml vendored Normal file
View file

@ -0,0 +1,19 @@
# LiteLLM Observatory Test Configuration
# This config is used by CI to spin up a temporary LiteLLM instance
# for running observatory tests against RC/stable releases.
#
# Add model definitions for the providers you want to test.
# Provider API keys are injected via environment variables in CI.
model_list:
- model_name: gpt-4o
litellm_params:
model: azure/gpt-4o
api_key: os.environ/AZURE_API_KEY
api_base: os.environ/AZURE_API_BASE
- model_name: gpt-4o-mini
litellm_params:
model: azure/gpt-4o-mini
api_key: os.environ/AZURE_API_KEY
api_base: os.environ/AZURE_API_BASE

View file

@ -1,62 +0,0 @@
[[IgnoredVulns]]
id = "GHSA-69hj-f68j-2879"
ignoreUntil = 2027-02-01
reason = "filelock UnixFileLock truncates and chmods a file another user renames or hard-links in at the lock path, in every release before 4.2.1, which needs Python >= 3.11. Python 3.11+ now locks 4.2.4 and only the Python 3.10 resolution stays on 3.20.3, held below 3.20.4 by a [tool.uv] constraint because every newer 3.10-compatible release carries more filelock advisories. litellm's CLI takes FileLock only inside ~/.litellm, which it creates owner-only, and huggingface_hub takes it in the Hugging Face cache, which litellm reaches only to download the Kimi K3 tokenizer for Bedrock token counting, so the attack needs another local user who can rename or link files in that cache. Remove once 3.10 is dropped"
[[IgnoredVulns]]
id = "GHSA-8mx5-7m5m-r77g"
ignoreUntil = 2027-02-01
reason = "filelock WindowsFileLock ignores mode in every release from 3.10.0 before 4.2.1, which needs Python >= 3.11. Python 3.11+ now locks 4.2.4 and only the Python 3.10 resolution stays on 3.20.3, held below 3.20.4 by a [tool.uv] constraint because every newer 3.10-compatible release carries more filelock advisories. The proxy image is Linux, so only Windows installs on Python 3.10 run it, and there litellm's CLI takes FileLock only inside the owner-only ~/.litellm, whose lock file holds no data, while huggingface_hub takes it in the Hugging Face cache, which litellm reaches only to download the Kimi K3 tokenizer. Remove once 3.10 is dropped"
[[IgnoredVulns]]
id = "GHSA-pcg2-g6cj-q92c"
ignoreUntil = 2027-02-01
reason = "tornado 6.6 (published 2026-10-21) is the only fix and requires Python >= 3.11, so the Python 3.10 resolution stays on 6.5.10 while 3.11+ locks 6.6.1; litellm starts no tornado server or client, since semantic-router and aurelio-sdk only declare it and tenacity and sentry-sdk import it only to wrap tornado code when it is present, so tornado's HTTP header parsing is never reached; remove this entry once 3.10 is dropped"
[[IgnoredVulns]]
id = "GHSA-pw9p-v7r6-gx4p"
ignoreUntil = 2027-02-01
reason = "tornado 6.6 (published 2026-10-21) is the only fix and requires Python >= 3.11, so the Python 3.10 resolution stays on 6.5.10 while 3.11+ locks 6.6.1; litellm starts no tornado server or client, since semantic-router and aurelio-sdk only declare it and tenacity and sentry-sdk import it only to wrap tornado code when it is present, so tornado's RedirectHandler is never reached; remove this entry once 3.10 is dropped"
[[IgnoredVulns]]
id = "GHSA-r4j5-9xpp-3985"
ignoreUntil = 2027-02-01
reason = "tornado 6.6 (published 2026-10-21) is the only fix and requires Python >= 3.11, so the Python 3.10 resolution stays on 6.5.10 while 3.11+ locks 6.6.1; litellm starts no tornado server or client, since semantic-router and aurelio-sdk only declare it and tenacity and sentry-sdk import it only to wrap tornado code when it is present, so tornado's chunked body reading is never reached; remove this entry once 3.10 is dropped"
[[IgnoredVulns]]
id = "GHSA-45c3-73f7-pv4m"
ignoreUntil = 2027-02-01
reason = "dnspython 2.9.0 (published 2026-10-09) is the only fix and requires Python >= 3.11, so the Python 3.10 resolution stays on 2.8.0 while 3.11+ locks 2.9.2; the advisory is CPU use in dns.name.from_wire_parser and litellm only reaches dnspython through pydantic EmailStr, which never does DNS lookups; remove this entry once 3.10 is dropped or a 3.10-compatible fix ships"
[[IgnoredVulns]]
id = "GHSA-mmhr-3c5m-f64f"
ignoreUntil = 2027-02-01
reason = "soupsieve 4.0 is the first fix and requires Python >= 3.11.5, so Python 3.10 and 3.11.0-3.11.4 resolve 2.10 while newer Python versions resolve 4.1. litellm declares beautifulsoup4 in its ci dependency group and uses BeautifulSoup in tests/code_coverage_tests/bedrock_pricing.py to parse AWS pricing HTML with html.parser and find table rows and cells, not CSS selectors; no runtime litellm code imports soupsieve or beautifulsoup4. Remove once 3.10 is dropped and the minimum Python version is at least 3.11.5"
[[PackageOverrides]]
name = "pillow"
version = "12.3.0"
ecosystem = "PyPI"
vulnerability.ignore = true
effectiveUntil = 2027-02-01
reason = "pillow 13.0.0 requires Python >= 3.11, so the Python 3.10 resolution of the ci and dev groups and of the mlflow extra stays on 12.3.0; 3.11+ is on 13.0.0; pillow has never shipped an older-major release after the next major (no 10.x after 11.0.0, no 11.x after 12.0.0), so 13.x is the only fix line; extended to 2027-02-01 with maintainer approval, same date as every other Python 3.10 entry; remove once 3.10 is dropped"
[[IgnoredVulns]]
id = "GHSA-vvf4-9x63-8xvv"
ignoreUntil = 2027-02-01
reason = "dnspython 2.9.2 is the only fix and requires Python >= 3.11, so the Python 3.10 resolution stays on 2.8.0 while 3.11+ locks 2.9.2; the advisory is memory and CPU use when dns.rdata.from_text parses a WKS record from attacker-controlled zone file text, and litellm only reaches dnspython through pydantic EmailStr, which never parses zone files or does DNS lookups; remove this entry once 3.10 is dropped or a 3.10-compatible fix ships"
[[IgnoredVulns]]
id = "GHSA-5cjw-f85j-cv6r"
ignoreUntil = 2027-02-01
reason = "tornado 6.6.1 is the only fix and requires Python >= 3.11, so the Python 3.10 resolution stays on 6.5.10 while 3.11+ locks 6.6.1; litellm starts no tornado server or client, since semantic-router and aurelio-sdk only declare it and tenacity and sentry-sdk import it only to wrap tornado code when it is present, so tornado's RedirectHandler is never reached; remove this entry once 3.10 is dropped"
[[IgnoredVulns]]
id = "GHSA-6655-6qjp-jx7x"
ignoreUntil = 2027-02-01
reason = "soupsieve 3.0.2 is the first fix and requires Python >= 3.11.5, so Python 3.10 and 3.11.0-3.11.4 resolve 2.10 while newer Python versions resolve 4.1. The advisory is exponential regex backtracking when compiling an untrusted CSS selector. litellm declares beautifulsoup4 in its ci dependency group and uses BeautifulSoup only in tests/code_coverage_tests/bedrock_pricing.py, which calls find and find_all and never compiles a CSS selector; no runtime litellm code imports soupsieve or beautifulsoup4. Remove once 3.10 is dropped and the minimum Python version is at least 3.11.5"
[[IgnoredVulns]]
id = "GHSA-7g44-g9rc-x53w"
ignoreUntil = 2027-02-01
reason = "soupsieve 3.0.2 is the first fix and requires Python >= 3.11.5, so Python 3.10 and 3.11.0-3.11.4 resolve 2.10 while newer Python versions resolve 4.1. The advisory is unbounded recursion when parsing an untrusted CSS selector with deeply nested pseudo-classes. litellm declares beautifulsoup4 in its ci dependency group and uses BeautifulSoup only in tests/code_coverage_tests/bedrock_pricing.py, which calls find and find_all and never parses a CSS selector; no runtime litellm code imports soupsieve or beautifulsoup4. Remove once 3.10 is dropped and the minimum Python version is at least 3.11.5"

View file

@ -1,49 +0,0 @@
import sys
from collections.abc import Mapping
from pathlib import Path
from typing import Final
import maturin
from cli_package import PACKAGE_DIR, map_wheel
from maturin import (
build_sdist as build_sdist,
)
from maturin import (
get_requires_for_build_editable as get_requires_for_build_editable,
)
from maturin import (
get_requires_for_build_sdist as get_requires_for_build_sdist,
)
from maturin import (
get_requires_for_build_wheel as get_requires_for_build_wheel,
)
from maturin import (
prepare_metadata_for_build_editable as prepare_metadata_for_build_editable,
)
from maturin import (
prepare_metadata_for_build_wheel as prepare_metadata_for_build_wheel,
)
def build_wheel(
wheel_directory: str,
config_settings: Mapping[str, str | list[str]] | None = None,
metadata_directory: str | None = None,
) -> str:
filename: Final = maturin.build_wheel(wheel_directory, config_settings, metadata_directory)
map_wheel(Path(wheel_directory) / filename)
return filename
def build_editable(
wheel_directory: str,
config_settings: Mapping[str, str | list[str]] | None = None,
metadata_directory: str | None = None,
) -> str:
filename: Final = maturin.build_editable(wheel_directory, config_settings, metadata_directory)
map_wheel(Path(wheel_directory) / filename, source=Path(__file__).resolve().parents[2] / PACKAGE_DIR)
return filename
if __name__ == "__main__":
build_editable(sys.argv[1])

View file

@ -1,93 +0,0 @@
import argparse
import base64
import copy
import csv
import hashlib
import io
import os
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Final
from zipfile import ZipFile, ZipInfo
PACKAGE_DIR: Final = "litellm/cli"
PACKAGE_NAME: Final = "litellm_cli"
def editable_support(source: Path) -> tuple[tuple[str, bytes], ...]:
module: Final = "_litellm_cli_editable"
finder: Final = (
"import importlib.util\nimport sys\n\n"
"class _Finder:\n"
" @staticmethod\n"
" def find_spec(fullname, path=None, target=None):\n"
f" if fullname == {PACKAGE_NAME!r}:\n"
" return importlib.util.spec_from_file_location(\n"
f" fullname, {str(source / '__init__.py')!r},\n"
f" submodule_search_locations=[{str(source)!r}])\n"
" return None\n\n"
"sys.meta_path.insert(0, _Finder())\n"
)
return ((f"{module}.py", finder.encode()), (f"{module}.pth", f"import {module}\n".encode()))
def _name(name: str) -> str:
prefix: Final = f"{PACKAGE_DIR}/"
return f"{PACKAGE_NAME}/{name.removeprefix(prefix)}" if name.startswith(prefix) else name
def _info(original: ZipInfo) -> ZipInfo:
mapped: Final = copy.copy(original)
mapped.filename = _name(original.filename)
return mapped
def _record(entries: tuple[tuple[ZipInfo, bytes], ...], record: str) -> bytes:
buffer: Final = io.StringIO(newline="")
writer: Final = csv.writer(buffer, lineterminator="\n")
writer.writerows(
(
info.filename,
"sha256=" + base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode(),
str(len(data)),
)
for info, data in entries
)
writer.writerow((record, "", ""))
return buffer.getvalue().encode()
def map_wheel(wheel: Path, *, source: Path | None = None) -> None:
with ZipFile(wheel) as archive:
records: Final = tuple(info for info in archive.infolist() if info.filename.endswith(".dist-info/RECORD"))
if len(records) != 1:
raise ValueError("A wheel must contain exactly one RECORD")
entries: Final = tuple(
(_info(info), archive.read(info))
for info in archive.infolist()
if info != records[0] and (source is None or not info.filename.startswith(f"{PACKAGE_DIR}/"))
)
additions: Final = () if source is None else tuple((ZipInfo(name), data) for name, data in editable_support(source))
contents: Final = (*entries, *additions)
names: Final = tuple(info.filename for info, _ in contents)
if len(names) != len(frozenset(names)):
raise ValueError("The CLI package mapping would overwrite a wheel member")
if source is None and not any(name.startswith(f"{PACKAGE_NAME}/") for name in names):
raise ValueError("The wheel does not contain the CLI package")
with NamedTemporaryFile(dir=wheel.parent, suffix=".whl", delete=False) as temporary:
destination: Final = Path(temporary.name)
try:
with ZipFile(destination, "w") as archive:
for info, data in contents:
archive.writestr(info, data)
archive.writestr(records[0], _record(contents, records[0].filename))
os.replace(destination, wheel)
finally:
destination.unlink(missing_ok=True)
if __name__ == "__main__":
parser: Final = argparse.ArgumentParser()
parser.add_argument("wheels", nargs="+", type=Path)
for wheel in parser.parse_args().wheels:
map_wheel(wheel)

View file

@ -1,210 +0,0 @@
## Captured API proof
These are actual curl requests to a local proxy and a controlled HTTP provider, using synthetic records and keys. Addresses, header files and IDs below are placeholders. Response excerpts omit unrelated fields and credentials.
### Permission change: verified before and after
The baseline process ran clean relevant source at `6685525fa2a809e0d4bc2c13cb07aef8eec242c3`. The requests used the same bound key and org policy; the after request was rerun after the schema update: the org allowed only `gpt-4.1-mini`; the key allowed `usage-proof-model` and was bound with `metadata.customer_org_id`. No team/member was needed for this org-level comparison.
```sh
curl --silent --show-error --request POST "$PROXY_BASE/v1/chat/completions" \
--header "@$SCOPED_KEY_HEADERS" --header 'Content-Type: application/json' \
--data-binary @request.json
```
`request.json`:
```json
{
"model": "usage-proof-model",
"messages": [
{
"role": "user",
"content": "Synthetic before-after permission check"
}
]
}
```
Baseline: **HTTP 200**.
```json
{
"object": "chat.completion",
"model": "usage-proof-model",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "Controlled usage proof response.",
"role": "assistant"
},
"provider_specific_fields": {}
}
]
}
```
Current implementation: **HTTP 403**, with **zero controlled-provider calls**.
```json
{
"error": {
"message": "The requested model 'usage-proof-model' is not in the allowed models for this customer. Check the models this customer can use and try again.",
"type": "customer_model_access_denied",
"param": "model",
"code": "403"
}
}
```
### Current hierarchy and key intersection
Admin requests created the records below; each returned **201**. Member `budget_id` selects its template and `budget_override_id` identifies its optional override:
`POST /management/v1/customer_organizations`
```json
{
"customer_org_id": "<org-id>",
"alias": "Permissions and deletion proof",
"budget": {
"max_budget": 500,
"allowed_models": [
"gpt-4.1-mini",
"gpt-5.5"
]
},
"default_budgets": [
{
"name": "Standard",
"is_default": true,
"max_budget": 100,
"allowed_models": [
"gpt-4.1-mini"
]
}
]
}
```
`POST /management/v1/customer_teams`
```json
{
"customer_team_id": "<team-id>",
"parent_org_id": "<org-id>",
"alias": "Proof customer team",
"budget": {
"max_budget": 250,
"allowed_models": [
"gpt-4.1-mini",
"gpt-5.5"
]
}
}
```
`POST /management/v1/customer_members`
```json
{
"customer_member_id": "<member-id>",
"parent_team_id": "<team-id>",
"alias": "Proof customer member",
"budget_override": {
"max_budget": 100,
"allowed_models": [
"gpt-4.1-mini"
]
}
}
```
A synthetic `internal_user` was created with `auto_create_key: false`. The admin then created these keys through `POST /key/generate` (each **200**; returned credentials omitted):
| Key | models | metadata / budget binding |
| --- | --- | --- |
| A | `["gpt-4.1-mini", "gpt-5.5"]` | `{"customer_org_id":"<org-id>","customer_member_id":"<member-id>"}` |
| B | `["gpt-4.1-mini"]` | Same org/member binding |
| C | `["gpt-4.1-mini"]` | `budget_id: "<shared-budget-id>"`; no customer binding |
Inference curl uses the appropriate key header file. Captured request bodies:
```json
{
"model": "gpt-4.1-mini",
"messages": [
{
"role": "user",
"content": "Synthetic permission check"
}
]
}
```
```json
{
"model": "gpt-4.1-mini",
"input": "Synthetic permission check"
}
```
| State / key | Requested model | Chat HTTP | Responses HTTP | Provider calls per request |
| --- | --- | --- | --- | --- |
| Member restriction / A | gpt-4.1-mini | 200 | 200 | 1 |
| Member restriction / A | gpt-5.5 | 403 | 403 | 0 |
| Member widened; key B remains narrow | gpt-5.5 | 403 | 403 | 0 |
| Member widened; org narrowed / A | gpt-5.5 | 403 | 403 | 0 |
The widening PATCH was `{"budget_override":{"allowed_models":["gpt-4.1-mini","gpt-5.5"]}}` on the member. The org narrowing PATCH was `{"budget":{"allowed_models":["gpt-4.1-mini"]}}`. Key B still denied the model after the member widened.
### Cascade, retained history and shared key budget
**Same-revision comparison:** deleting the populated org without `cascade=true` returned **409**:
```json
{
"type": "urn:litellm:error:customer-hierarchy-has-children",
"title": "Has children",
"status": 409,
"detail": "Delete the customer's children before deleting the parent."
}
```
The confirmed cascade request returned **204**, with an empty body:
```sh
curl --silent --show-error --request DELETE \
"$PROXY_BASE/management/v1/customer_organizations/$ORG_ID?cascade=true" \
--header "@$ADMIN_HEADERS"
```
Subsequent GETs for that org, its team and its member each returned **404**. The existing demo organization still returned **200**.
Key C referenced the org-owned budget. After PATCHing that budget to `max_budget: 0`, its inference returned **422 `budget_exceeded` before and after cascade**, with zero provider calls. Its authenticated `GET /key/info` retained the same relation and limit (selected fields):
```json
{
"info": {
"budget_id": "<shared-budget-id>",
"litellm_budget_table": {
"max_budget": 0.0
}
}
}
```
The same admin daily query returned **200** before and after cascade:
```sh
curl --silent --show-error \
"$PROXY_BASE/management/v1/customer_organizations/daily/activity?customer_org_ids=$ORG_ID&start_date=$UTC_DATE&end_date=$UTC_DATE" \
--header "@$ADMIN_HEADERS"
```
Selected captured metadata was identical before and after; API-key/entity breakdowns are omitted:
```json
{
"metadata": {
"total_spend": 0.28,
"total_prompt_tokens": 200,
"total_completion_tokens": 40,
"total_tokens": 240,
"total_api_requests": 4,
"total_successful_requests": 2,
"total_failed_requests": 2
}
}
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View file

@ -1,69 +0,0 @@
The OpenAI passthrough route now resolves customer organization, team and effective member model restrictions before forwarding the request. It uses the existing customer resolver and model matcher, then checks the model again after hooks. Gateway key permissions still apply.
Actual curl proof used a controlled HTTP upstream. Before: PR head `43d55ab2b91e4cccccf014c5f813a2b70a4e89f9` merged with target `eb811913f1`, before the fix. After: the implementation linked from the current PR description. Counts below come from the upstream request log.
Setup requests, using synthetic identifiers:
```http
POST /management/v1/customer_organizations
{"customer_org_id":"proof-org","alias":"Synthetic passthrough permission proof","budget":{"max_budget":100,"allowed_models":["gpt-4.1-mini"]}}
POST /user/new
{"user_id":"proof-user","user_role":"internal_user","auto_create_key":false}
POST /key/generate
{"user_id":"proof-user","models":["gpt-4.1-mini","gpt-5.5"],"metadata":{"customer_org_id":"proof-org"}}
POST /key/generate
{"user_id":"proof-user","models":["gpt-4.1-mini"],"metadata":{"customer_org_id":"proof-org"}}
```
The first key permits both models; the second permits only mini. Credentials from both responses are omitted.
```bash
curl "$GATEWAY_BASE_URL/openai/v1/chat/completions" \
--header "@$CUSTOMER_KEY_AB_HEADERS" \
--header 'Content-Type: application/json' \
--data '{"model":"gpt-5.5","messages":[{"role":"user","content":"Synthetic passthrough policy check"}]}'
```
Before: HTTP 200, one upstream call. Captured response excerpt:
```json
{
"model": "gpt-5.5",
"usage": {
"prompt_tokens": 100,
"completion_tokens": 20,
"total_tokens": 120
}
}
```
After: HTTP 403, zero upstream calls. Captured response:
```json
{
"error": {
"message": "The requested model 'gpt-5.5' is not in the allowed models for this customer. Check the models this customer can use and try again.",
"type": "customer_model_access_denied",
"param": "model",
"code": "403"
}
}
```
The same key still receives HTTP 200 for `gpt-4.1-mini`, with one upstream call. After widening the organization to permit both models, the mini-only key still receives HTTP 403 for `gpt-5.5`, with zero upstream calls:
```json
{
"error": {
"message": "The requested model 'gpt-5.5' is not available for this API key, or the model name is invalid. Check the models available to you and try again.",
"type": "key_model_access_denied",
"param": "model",
"code": "403"
}
}
```
The proof deletes its synthetic organization afterward. Registered-route regressions also cover team restrictions, member templates and overrides, hook model rewrites, model-less file retrieval, and the existing configured passthrough model contract.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View file

@ -10,7 +10,6 @@ import warnings
from collections.abc import Callable, Iterable, Mapping, Sequence from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from itertools import chain
from types import MappingProxyType from types import MappingProxyType
from typing import Final from typing import Final
@ -18,6 +17,7 @@ import yaml
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows" WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows"
CIRCLECI_CONFIG = REPO_ROOT / ".circleci" / "config.yml"
ALLOWLIST_FILE = REPO_ROOT / ".github" / "ci-coverage-allowlist.yml" ALLOWLIST_FILE = REPO_ROOT / ".github" / "ci-coverage-allowlist.yml"
TESTS_ROOT = REPO_ROOT / "tests" TESTS_ROOT = REPO_ROOT / "tests"
@ -25,18 +25,14 @@ ALLOWLIST_KEYS = frozenset({"description", "test_paths", "dockerfiles"})
PATH_FILTER_KEYS = frozenset({"paths", "paths-ignore"}) PATH_FILTER_KEYS = frozenset({"paths", "paths-ignore"})
TEST_PATH_KEYS = frozenset({"test-path", "test-paths", "test_path"}) TEST_PATH_KEYS = frozenset({"test-path", "test-paths", "test_path"})
DOCKERFILE_INPUT_KEYS = frozenset({"file", "dockerfile"}) DOCKERFILE_INPUT_KEYS = frozenset({"file", "dockerfile"})
TEST_RUNNER_RE = re.compile(r"\bpytest\b|\bhelm unittest\b|\bplaywright test\b|\bpython[0-9.]*\s") TEST_RUNNER_RE = re.compile(r"\bpytest\b|\bcircleci tests\b|\bhelm unittest\b|\bplaywright test\b|\bpython[0-9.]*\s")
IMAGE_BUILD_RE = re.compile(r"\bdocker\s+(?:buildx\s+)?build\b") IMAGE_BUILD_RE = re.compile(r"\bdocker\s+(?:buildx\s+)?build\b")
TEST_TOKEN_RE = re.compile(r"tests/[A-Za-z0-9_./*?\[\]-]+") TEST_TOKEN_RE = re.compile(r"tests/[A-Za-z0-9_./*?-]+")
IGNORE_ARG_RE: Final = re.compile(r"--(?:ignore(?:-glob)?|deselect)[= ](\S+)") IGNORE_ARG_RE: Final = re.compile(r"--ignore(?:-glob)?[= ](\S+)")
DOCKERFILE_TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]*Dockerfile[A-Za-z0-9_.-]*") DOCKERFILE_TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]*Dockerfile[A-Za-z0-9_.-]*")
COMMENT_RE = re.compile(r"^\s*#.*$", re.MULTILINE) COMMENT_RE = re.compile(r"^\s*#.*$", re.MULTILINE)
GLOB_CHARS = frozenset("*?[") GLOB_CHARS = frozenset("*?")
DEDICATED_INTEGRATION_WORKFLOWS: Final = MappingProxyType( DEDICATED_INTEGRATION_WORKFLOWS: Final = MappingProxyType({"image-scan.yml": "tests/integration/database/image/"})
{
"image-scan.yml": "tests/integration/database/image/",
}
)
# Trees whose jobs are sharded with no catch-all bucket, so every child that holds # Trees whose jobs are sharded with no catch-all bucket, so every child that holds
# tests has to be named by some shard or it runs nowhere. A child listed here is # tests has to be named by some shard or it runs nowhere. A child listed here is
@ -119,7 +115,9 @@ def _scalars(node: object, key: str) -> tuple[Scalar, ...]:
def _config_files() -> tuple[pathlib.Path, ...]: def _config_files() -> tuple[pathlib.Path, ...]:
return tuple(sorted(path for path in WORKFLOW_DIR.iterdir() if path.suffix in (".yml", ".yaml"))) workflows = tuple(sorted(path for path in WORKFLOW_DIR.iterdir() if path.suffix in (".yml", ".yaml")))
circleci = (CIRCLECI_CONFIG,) if CIRCLECI_CONFIG.is_file() else ()
return workflows + circleci
def _all_scalars() -> tuple[Scalar, ...]: def _all_scalars() -> tuple[Scalar, ...]:
@ -245,6 +243,7 @@ def _describe(paths: tuple[str, ...]) -> str:
return f"{len(paths)} test file(s) invoked by no job: {names}{suffix}" return f"{len(paths)} test file(s) invoked by no job: {names}{suffix}"
GLOB_CALL_RE = re.compile(r'circleci tests glob "([^"]+)"')
KEYWORD_RE = re.compile(r"-k\s+\\?[\"']([^\"'\\]+)") KEYWORD_RE = re.compile(r"-k\s+\\?[\"']([^\"'\\]+)")
@ -317,14 +316,16 @@ def _keyword_terms(
def _slices() -> tuple[Slice, ...]: def _slices() -> tuple[Slice, ...]:
if not CIRCLECI_CONFIG.exists():
return ()
test_files: Final = frozenset(_test_files()) test_files: Final = frozenset(_test_files())
jobs: Final = yaml.safe_load(CIRCLECI_CONFIG.read_text()).get("jobs", {})
return tuple( return tuple(
Slice(job=f"{path.name}:{job}", globs=globs, named=named, required=required, excluded=excluded, understood=understood) Slice(job=job, globs=globs, named=named, required=required, excluded=excluded, understood=understood)
for path in _config_files() for job, body in jobs.items()
for job, body in yaml.safe_load(path.read_text()).get("jobs", {}).items()
for text in ("\n".join(_strings(body)),) for text in ("\n".join(_strings(body)),)
if "pytest" in text if "pytest" in text
for globs in (tuple(token for token in TEST_TOKEN_RE.findall(text) if GLOB_CHARS & set(token)),) for globs in (tuple(GLOB_CALL_RE.findall(text)),)
for named in (frozenset(TEST_TOKEN_RE.findall(text)) & test_files,) for named in (frozenset(TEST_TOKEN_RE.findall(text)) & test_files,)
for required, excluded, understood in ( for required, excluded, understood in (
_keyword_terms(tuple(KEYWORD_RE.findall(text)), attributable=len(globs) < 2), _keyword_terms(tuple(KEYWORD_RE.findall(text)), attributable=len(globs) < 2),
@ -351,11 +352,16 @@ def _matchable_names(relative_path: str) -> frozenset[str]:
def _workflow_named_tokens() -> frozenset[str]: def _workflow_named_tokens() -> frozenset[str]:
"""Test tokens a GitHub Actions job names directly.
A CircleCI `-k` that deselects a file no longer means the file runs nowhere once a
workflow names it, so the slice check has to credit those the same way the census does.
"""
return _invoked_test_tokens( return _invoked_test_tokens(
scalar scalar
for path in _config_files() for path in _config_files()
if path != CIRCLECI_CONFIG
for scalar in _scalars(yaml.safe_load(path.read_text(encoding="utf-8")), path.name) for scalar in _scalars(yaml.safe_load(path.read_text(encoding="utf-8")), path.name)
if not KEYWORD_RE.search(scalar.value)
) )
@ -560,28 +566,13 @@ def _owns(workflow: str, relative_path: str) -> bool:
return owned_root is not None and relative_path.startswith(owned_root) return owned_root is not None and relative_path.startswith(owned_root)
def _expression_suites(value: str) -> tuple[str, ...]:
suites: Final = chain.from_iterable(map(json.loads, re.findall(r"'(\[[^']*\])'", value)))
return tuple(suite for suite in suites if isinstance(suite, str) and "{" not in suite)
def _matrix_suites(value: object) -> tuple[str, ...]:
if isinstance(value, list):
return tuple(suite for suite in value if isinstance(suite, str))
if isinstance(value, str):
return _expression_suites(value)
return ()
def _tokens_outside_owner(relative_path: str, workflow_tokens: Mapping[str, frozenset[str]]) -> frozenset[str]: def _tokens_outside_owner(relative_path: str, workflow_tokens: Mapping[str, frozenset[str]]) -> frozenset[str]:
return frozenset[str]().union( return frozenset[str]().union(
*(tokens for workflow, tokens in workflow_tokens.items() if not _owns(workflow, relative_path)) *(tokens for workflow, tokens in workflow_tokens.items() if not _owns(workflow, relative_path))
) )
def _integration_ownership( def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozenset[str], tuple[Finding, ...]]:
repo_root: pathlib.Path = REPO_ROOT, *, allowlist: Allowlist | None = None
) -> tuple[frozenset[str], tuple[Finding, ...]]:
runner: Final = repo_root / "tests/integration/run.py" runner: Final = repo_root / "tests/integration/run.py"
if not runner.exists(): if not runner.exists():
return frozenset(), () return frozenset(), ()
@ -598,19 +589,26 @@ def _integration_ownership(
browser_manifest: Final = repo_root / "tests/e2e/ui/tests/integrationCritical/expected.json" browser_manifest: Final = repo_root / "tests/e2e/ui/tests/integrationCritical/expected.json"
browser_nodes: Final = json.loads(browser_manifest.read_text()) if browser_manifest.exists() else () browser_nodes: Final = json.loads(browser_manifest.read_text()) if browser_manifest.exists() else ()
browser_paths: Final = frozenset(node.split("::", 1)[0] for node in browser_nodes) browser_paths: Final = frozenset(node.split("::", 1)[0] for node in browser_nodes)
integration_path: Final = repo_root / ".github/workflows/test-integration.yml" circle_path: Final = repo_root / ".circleci/config.yml"
integration: Final = yaml.safe_load(integration_path.read_text()) if integration_path.exists() else {} circle: Final = yaml.safe_load(circle_path.read_text()) if circle_path.exists() else {}
integration_jobs: Final = integration.get("jobs", {}) circle_test_path_tokens: Final = _invoked_test_tokens(
scalar for scalar in _scalars(circle, "config.yml") if scalar.key == "test_path"
)
steps: Final = circle.get("jobs", {}).get("integration_contracts", {}).get("steps", ())
invoked: Final = any( invoked: Final = any(
".github/scripts/ci/run_integration.sh" in scalar.value ".circleci/scripts/run_integration.sh" in scalar.value
for scalar in _scalars(integration_jobs, "integration") for scalar in _scalars(steps, "integration_contracts")
if scalar.key == "run" if scalar.key == "command"
) )
scheduled: Final = frozenset( scheduled: Final = frozenset(
suite suite
for body in integration_jobs.values() for job in circle.get("workflows", {}).get("integration", {}).get("jobs", ())
if any(".github/scripts/ci/run_integration.sh" in scalar.value for scalar in _scalars(body, "integration")) if isinstance(job, dict) and "integration_contracts" in job
for suite in _matrix_suites(body.get("strategy", {}).get("matrix", {}).get("suite", ())) for suite in job["integration_contracts"]
.get("matrix", {})
.get("parameters", {})
.get("suite", (job["integration_contracts"].get("suite"),))
if isinstance(suite, str)
) )
required: Final = (frozenset({"browser"}) if browser_paths else frozenset()) | frozenset( required: Final = (frozenset({"browser"}) if browser_paths else frozenset()) | frozenset(
group group
@ -632,27 +630,19 @@ def _integration_ownership(
} }
) )
gha_tokens: Final = frozenset[str]().union(*workflow_tokens.values()) gha_tokens: Final = frozenset[str]().union(*workflow_tokens.values())
invoked_github_files: Final = frozenset( circle_owned: Final = (
path for path in github_files if any(_token_covers(token, path) for token in gha_tokens)
)
grouped: Final = (
frozenset(str(path.relative_to(repo_root)) for path in integration_root.rglob("test_*.py")) - github_files frozenset(str(path.relative_to(repo_root)) for path in integration_root.rglob("test_*.py")) - github_files
) )
findings: Final = ( findings: Final = (
tuple( tuple(
Finding(path, "integration file outside GITHUB_FILES is selected by GitHub Actions") Finding(path, "integration file outside GITHUB_FILES is selected by GitHub Actions")
for path in sorted(grouped) for path in sorted(circle_owned)
if any( if any(_token_covers(token, path) for token in gha_tokens)
_token_covers(token, path)
for workflow, tokens in workflow_tokens.items()
if workflow != "test-integration.yml"
for token in tokens
)
) )
+ tuple( + tuple(
Finding(path, "GitHub-owned integration contract has no invoking job") Finding(path, "GitHub-owned integration contract has no invoking job")
for path in sorted(github_files) for path in sorted(github_files)
if path not in invoked_github_files and not (allowlist is not None and allowlist.covers_test(path)) if not any(_token_covers(token, path) for token in gha_tokens | circle_test_path_tokens)
) )
+ tuple( + tuple(
Finding(path, "GitHub-owned integration file is missing") Finding(path, "GitHub-owned integration file is missing")
@ -668,7 +658,6 @@ def _integration_ownership(
browser_commands: Final = tuple( browser_commands: Final = tuple(
scalar.value scalar.value
for path in (repo_root / ".github/workflows").glob("*.y*ml") for path in (repo_root / ".github/workflows").glob("*.y*ml")
if path.name != "test-integration.yml"
for scalar in _scalars(yaml.safe_load(path.read_text()), path.name) for scalar in _scalars(yaml.safe_load(path.read_text()), path.name)
if scalar.key in {"run", "command"} if scalar.key in {"run", "command"}
) )
@ -701,14 +690,14 @@ def _integration_ownership(
else () else ()
) )
group_findings: Final = tuple( group_findings: Final = tuple(
Finding(group, "canonical integration group is not scheduled by GitHub Actions") Finding(group, "canonical integration group is not scheduled by CircleCI")
for group in sorted(required - scheduled) for group in sorted(required - scheduled)
) + tuple(Finding(path, "canonical node must have exactly one integration group") for path in sorted(ungrouped)) ) + tuple(Finding(path, "canonical node must have exactly one integration group") for path in sorted(ungrouped))
if not paths or not invoked or not scheduled: if not paths or not invoked or not scheduled:
return frozenset(), findings + ( return frozenset(), findings + (
Finding(str(runner.relative_to(repo_root)), "dedicated GitHub Actions runner is missing"), Finding(str(runner.relative_to(repo_root)), "dedicated CircleCI runner is missing"),
) )
return paths | browser_paths | invoked_github_files, findings + group_findings + browser_findings + exclusion_findings return paths | browser_paths | github_files, findings + group_findings + browser_findings + exclusion_findings
def main() -> int: def main() -> int:
@ -720,7 +709,7 @@ def main() -> int:
allowlist = _load_allowlist() allowlist = _load_allowlist()
scalars = _all_scalars() scalars = _all_scalars()
integration_paths, ownership_findings = _integration_ownership(allowlist=allowlist) integration_paths, ownership_findings = _integration_ownership()
test_findings = _uncovered_tests( test_findings = _uncovered_tests(
allowlist, allowlist,
_invoked_selections(scalars) + (Selection(included=integration_paths, ignored=frozenset()),), _invoked_selections(scalars) + (Selection(included=integration_paths, ignored=frozenset()),),
@ -742,8 +731,8 @@ def main() -> int:
) )
if ownership_findings: if ownership_findings:
browser_remedy = ( browser_remedy = (
" The browser group in test-integration.yml owns the integrationCritical Playwright contracts. " " CircleCI's browser group owns the integrationCritical Playwright contracts, so no GitHub workflow may "
"Other workflows and the default Playwright config must exclude them." "select them and the default Playwright config must exclude them."
if any("browser" in finding.detail or "Playwright" in finding.detail for finding in ownership_findings) if any("browser" in finding.detail or "Playwright" in finding.detail for finding in ownership_findings)
else "" else ""
) )

View file

@ -16,7 +16,7 @@ run_full() {
} }
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
classify="${here}/ci/classify_changes.sh" classify="${here}/../../.circleci/scripts/classify_changes.sh"
[ -n "${PR_NUMBER:-}" ] || run_full "not a pull_request event" [ -n "${PR_NUMBER:-}" ] || run_full "not a pull_request event"
[ -n "${REPO:-}" ] || run_full "no repository in the environment" [ -n "${REPO:-}" ] || run_full "no repository in the environment"

View file

@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(git rev-parse --show-toplevel)
toolchain=$(sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$root/litellm/rust/rust-toolchain.toml")
test -n "$toolchain"
rustup toolchain install "$toolchain" --profile minimal --component rustfmt --component clippy --no-self-update
{
echo "RUSTUP_TOOLCHAIN=$toolchain"
echo "CARGO_NET_RETRY=5"
echo "CARGO_HTTP_MULTIPLEXING=false"
echo "SQLX_OFFLINE=${SQLX_OFFLINE:-true}"
} >> "${GITHUB_ENV:?GITHUB_ENV is required}"

View file

@ -128,20 +128,6 @@ def main(
member.filename member.filename
for member in wheel_members for member in wheel_members
if member.filename.endswith((".pdb", ".dwp", ".rlib", ".rmeta", "Cargo.toml", "Cargo.lock")) if member.filename.endswith((".pdb", ".dwp", ".rlib", ".rmeta", "Cargo.toml", "Cargo.lock"))
or member.filename.startswith(
(
"litellm/deploy/",
"litellm/ui/",
"litellm/vscode-extension/",
"litellm/enterprise/",
"litellm/proxy-extras/",
"litellm/rust/",
"litellm/packaging/",
"litellm/scripts/",
"litellm/runtime/",
"litellm/schema.prisma",
)
)
or any(part.endswith(".dSYM") for part in PurePosixPath(member.filename).parts) or any(part.endswith(".dSYM") for part in PurePosixPath(member.filename).parts)
) )
native_member: Final = native_members[0] native_member: Final = native_members[0]
@ -228,7 +214,7 @@ def main(
native_module: Final = load_native_module(native_path) native_module: Final = load_native_module(native_path)
native_module_loads: Final = native_module is not None native_module_loads: Final = native_module is not None
panic_test_hook_absent: Final = native_module is not None and not hasattr(native_module, "_panic_for_test") panic_test_hook_absent: Final = native_module is not None and not hasattr(native_module, "_panic_for_test")
native_size_limit: Final = 55_000_000 native_size_limit: Final = 48_000_000
native_size_within_limit: Final = native_member.file_size <= native_size_limit native_size_within_limit: Final = native_member.file_size <= native_size_limit
validations: Final = ( validations: Final = (
(f"Python tag is {EXPECTED_PYTHON_TAG}", python_tag == EXPECTED_PYTHON_TAG), (f"Python tag is {EXPECTED_PYTHON_TAG}", python_tag == EXPECTED_PYTHON_TAG),

View file

@ -16,8 +16,8 @@ on:
pull_request: pull_request:
paths: paths:
- .github/workflows/auto-close-duplicates.yml - .github/workflows/auto-close-duplicates.yml
- litellm/scripts/auto-close-duplicates.ts - scripts/auto-close-duplicates.ts
- litellm/scripts/auto-close-duplicates.test.ts - scripts/auto-close-duplicates.test.ts
permissions: {} permissions: {}
@ -40,7 +40,7 @@ jobs:
bun-version: "1.4.0" bun-version: "1.4.0"
- name: Test the sweep - name: Test the sweep
run: bun test litellm/scripts/auto-close-duplicates.test.ts run: bun test scripts/auto-close-duplicates.test.ts
sweep: sweep:
if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm' if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm'
@ -62,7 +62,7 @@ jobs:
bun-version: "1.4.0" bun-version: "1.4.0"
- name: Close unanswered duplicates, reopen ones the reporter answered - name: Close unanswered duplicates, reopen ones the reporter answered
run: bun run litellm/scripts/auto-close-duplicates.ts run: bun run scripts/auto-close-duplicates.ts
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run == true }} DRY_RUN: ${{ inputs.dry_run == true }}

View file

@ -3,9 +3,9 @@ name: Check Schema Sync
on: on:
pull_request: pull_request:
paths: paths:
- 'litellm/schema.prisma' - 'schema.prisma'
- 'litellm/proxy/schema.prisma' - 'litellm/proxy/schema.prisma'
- 'litellm/proxy-extras/litellm_proxy_extras/schema.prisma' - 'litellm-proxy-extras/litellm_proxy_extras/schema.prisma'
permissions: permissions:
contents: read contents: read
@ -16,7 +16,7 @@ concurrency:
jobs: jobs:
check-sync: check-sync:
name: Verify litellm/schema.prisma copies match the canonical schema name: Verify schema.prisma copies match root
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
@ -27,7 +27,7 @@ jobs:
- name: Reject symlinked schema files - name: Reject symlinked schema files
run: | run: |
for f in litellm/schema.prisma litellm/proxy/schema.prisma litellm/proxy-extras/litellm_proxy_extras/schema.prisma; do for f in schema.prisma litellm/proxy/schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma; do
if [ -L "$f" ]; then if [ -L "$f" ]; then
echo "::error file=$f::$f is a symlink, which is not allowed" echo "::error file=$f::$f is a symlink, which is not allowed"
exit 1 exit 1
@ -38,24 +38,24 @@ jobs:
run: | run: |
EXIT=0 EXIT=0
diff litellm/schema.prisma litellm/proxy/schema.prisma || { diff schema.prisma litellm/proxy/schema.prisma || {
echo "::error file=litellm/proxy/schema.prisma::litellm/proxy/schema.prisma differs from root litellm/schema.prisma" echo "::error file=litellm/proxy/schema.prisma::litellm/proxy/schema.prisma differs from root schema.prisma"
EXIT=1 EXIT=1
} }
diff litellm/schema.prisma litellm/proxy-extras/litellm_proxy_extras/schema.prisma || { diff schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma || {
echo "::error file=litellm/proxy-extras/litellm_proxy_extras/schema.prisma::litellm/proxy-extras/litellm_proxy_extras/schema.prisma differs from root litellm/schema.prisma" echo "::error file=litellm-proxy-extras/litellm_proxy_extras/schema.prisma::litellm-proxy-extras/litellm_proxy_extras/schema.prisma differs from root schema.prisma"
EXIT=1 EXIT=1
} }
if [ "$EXIT" -ne 0 ]; then if [ "$EXIT" -ne 0 ]; then
echo "" echo ""
echo "Schema files are out of sync." echo "Schema files are out of sync."
echo "The root litellm/schema.prisma is the source of truth." echo "The root schema.prisma is the source of truth."
echo "" echo ""
echo "To fix, run from the repo root:" echo "To fix, run from the repo root:"
echo " cp litellm/schema.prisma litellm/proxy/schema.prisma" echo " cp schema.prisma litellm/proxy/schema.prisma"
echo " cp litellm/schema.prisma litellm/proxy-extras/litellm_proxy_extras/schema.prisma" echo " cp schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma"
exit 1 exit 1
fi fi

View file

@ -26,24 +26,17 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install the pinned uv the cron image ships - name: Build the Render cron image
env: run: docker build -f tests/e2e/claude_code/cron_vm/Dockerfile -t compat-matrix:${{ github.sha }} tests/e2e
UV_VERSION: 0.10.9
UV_SHA256: 20d79708222611fa540b5c9ed84f352bcd3937740e51aacc0f8b15b271c57594
run: |
set -euo pipefail
curl -fsSLo "$RUNNER_TEMP/uv.tar.gz" "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz"
echo "${UV_SHA256} $RUNNER_TEMP/uv.tar.gz" | sha256sum -c -
mkdir -p "$RUNNER_TEMP/bin"
tar -xzf "$RUNNER_TEMP/uv.tar.gz" -C "$RUNNER_TEMP/bin" --strip-components=1 uv-x86_64-unknown-linux-gnu/uv
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Resolve and install the Claude Code CLI as an unprivileged user - name: Resolve and install the Claude Code CLI as the cron user
run: | run: |
set -euo pipefail docker run --rm compat-matrix:${{ github.sha }} bash -c '
whoami set -euo pipefail
gh --version whoami
uv --version gh --version
version="$(uv run --no-project --python 3.12 python tests/e2e/cli_harness/npm_version_resolver.py @anthropic-ai/claude-code)" uv --version
tests/e2e/claude_code/cron_vm/install_claude_code.sh "${version}" "$RUNNER_TEMP/claude-cli" version="$(uv run --no-project --python 3.12 python /opt/litellm/tests/e2e/cli_harness/npm_version_resolver.py @anthropic-ai/claude-code)"
"$RUNNER_TEMP/claude-cli/claude" --version /opt/litellm/tests/e2e/claude_code/cron_vm/install_claude_code.sh "${version}" /tmp/claude-cli
/tmp/claude-cli/claude --version
'

View file

@ -3,7 +3,7 @@ name: Conventional PR Title
# Squash-merge replaces the merge commit subject with the PR title, so # Squash-merge replaces the merge commit subject with the PR title, so
# enforcing Conventional Commits at the PR-title level is what actually gates # enforcing Conventional Commits at the PR-title level is what actually gates
# the commits that land on the default branch. The local commit-msg hook # the commits that land on the default branch. The local commit-msg hook
# (litellm/scripts/githooks/commit-msg) is a best-effort assist; this workflow is the gate. # (.githooks/commit-msg) is a best-effort assist; this workflow is the gate.
# #
# See https://www.conventionalcommits.org/en/v1.0.0/ # See https://www.conventionalcommits.org/en/v1.0.0/
@ -28,7 +28,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
# Must mirror the type list in litellm/scripts/githooks/commit-msg. # Must mirror the type list in .githooks/commit-msg.
types: | types: |
feat feat
fix fix

View file

@ -49,15 +49,7 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
HEAD_REF: ${{ github.event.pull_request.head.ref }} HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: | run: |
guard=litellm/scripts/ci_cd/cost_map_guard.py uv run --no-project python ci_cd/cost_map_guard.py --base "$MERGE_BASE" --head "$HEAD_SHA" --head-ref "$HEAD_REF"
# TODO: Drop the legacy path after this lands on main.
if [ ! -f "$guard" ]; then
guard=scripts/ci_cd/cost_map_guard.py
fi
if [ ! -f "$guard" ]; then
guard=ci_cd/cost_map_guard.py
fi
uv run --no-project python "$guard" --base "$MERGE_BASE" --head "$HEAD_SHA" --head-ref "$HEAD_REF"
- name: Check whether the pull request changes the cost map - name: Check whether the pull request changes the cost map
id: changed id: changed
env: env:
@ -77,5 +69,5 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
git show "${HEAD_SHA}:model_prices_and_context_window.json" > "$RUNNER_TEMP/model_prices_and_context_window.json" git show "${HEAD_SHA}:model_prices_and_context_window.json" > "$RUNNER_TEMP/model_prices_and_context_window.json"
cargo run --locked --quiet --manifest-path litellm/rust/Cargo.toml -p litellm-model-catalog --bin check-registry \ cargo run --locked --quiet --manifest-path litellm-rust/Cargo.toml -p litellm-model-catalog --bin check-registry \
-- "$RUNNER_TEMP/model_prices_and_context_window.json" -- "$RUNNER_TEMP/model_prices_and_context_window.json"

View file

@ -8,7 +8,7 @@ on:
paths: paths:
- model_prices_and_context_window.json - model_prices_and_context_window.json
- litellm/model_prices_and_context_window_backup.json - litellm/model_prices_and_context_window_backup.json
- litellm/scripts/ci_cd/cost_map_pins.json - ci_cd/cost_map_pins.json
permissions: {} permissions: {}
@ -28,8 +28,8 @@ jobs:
BEFORE_SHA: ${{ github.event.before }} BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.event.after }} AFTER_SHA: ${{ github.event.after }}
FORCED: ${{ github.event.forced }} FORCED: ${{ github.event.forced }}
COST_MAP_PATHS: '["model_prices_and_context_window.json", "litellm/model_prices_and_context_window_backup.json", "litellm/scripts/ci_cd/cost_map_pins.json"]' COST_MAP_PATHS: '["model_prices_and_context_window.json", "litellm/model_prices_and_context_window_backup.json", "ci_cd/cost_map_pins.json"]'
PINS_PATHS: '["litellm/scripts/ci_cd/cost_map_pins.json"]' PINS_PATHS: '["ci_cd/cost_map_pins.json"]'
run: | run: |
if [[ "$BEFORE_SHA" =~ ^0+$ || "$FORCED" == true ]] \ if [[ "$BEFORE_SHA" =~ ^0+$ || "$FORCED" == true ]] \
|| ! gh api "repos/${GITHUB_REPOSITORY}/compare/${BEFORE_SHA}...${AFTER_SHA}?per_page=1" \ || ! gh api "repos/${GITHUB_REPOSITORY}/compare/${BEFORE_SHA}...${AFTER_SHA}?per_page=1" \

View file

@ -13,9 +13,9 @@ on:
- .github/workflows/duplicate_issue_check.yml - .github/workflows/duplicate_issue_check.yml
- .github/prompts/duplicate-issue-check.md - .github/prompts/duplicate-issue-check.md
- .github/prompts/duplicate-issue-check.schema.json - .github/prompts/duplicate-issue-check.schema.json
- litellm/scripts/flag-duplicate-issue.ts - scripts/flag-duplicate-issue.ts
- litellm/scripts/flag-duplicate-issue.test.ts - scripts/flag-duplicate-issue.test.ts
- litellm/scripts/auto-close-duplicates.ts - scripts/auto-close-duplicates.ts
permissions: {} permissions: {}
@ -38,7 +38,7 @@ jobs:
bun-version: "1.4.0" bun-version: "1.4.0"
- name: Test the flag step - name: Test the flag step
run: bun test litellm/scripts/flag-duplicate-issue.test.ts run: bun test scripts/flag-duplicate-issue.test.ts
classify: classify:
if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm' if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm'
@ -125,7 +125,7 @@ jobs:
- name: Checkout scripts - name: Checkout scripts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:
sparse-checkout: litellm/scripts sparse-checkout: scripts
persist-credentials: false persist-credentials: false
- name: Setup Bun - name: Setup Bun
@ -134,7 +134,7 @@ jobs:
bun-version: "1.4.0" bun-version: "1.4.0"
- name: Comment and label - name: Comment and label
run: bun run litellm/scripts/flag-duplicate-issue.ts run: bun run scripts/flag-duplicate-issue.ts
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERDICT: ${{ needs.classify.outputs.verdict }} VERDICT: ${{ needs.classify.outputs.verdict }}

View file

@ -8,8 +8,8 @@ on:
paths: paths:
- "uv.lock" - "uv.lock"
- "pyproject.toml" - "pyproject.toml"
- "litellm/proxy-extras/pyproject.toml" - "litellm-proxy-extras/pyproject.toml"
- "litellm/enterprise/pyproject.toml" - "enterprise/pyproject.toml"
permissions: {} permissions: {}
@ -130,12 +130,8 @@ jobs:
} }
check_deps base/pyproject.toml pr/pyproject.toml "pyproject.toml" || had_error=1 check_deps base/pyproject.toml pr/pyproject.toml "pyproject.toml" || had_error=1
base_extras=base/litellm/proxy-extras/pyproject.toml check_deps base/litellm-proxy-extras/pyproject.toml pr/litellm-proxy-extras/pyproject.toml "litellm-proxy-extras/pyproject.toml" || had_error=1
[ -f "$base_extras" ] || base_extras=base/litellm-proxy-extras/pyproject.toml check_deps base/enterprise/pyproject.toml pr/enterprise/pyproject.toml "enterprise/pyproject.toml" || had_error=1
base_enterprise=base/litellm/enterprise/pyproject.toml
[ -f "$base_enterprise" ] || base_enterprise=base/enterprise/pyproject.toml
check_deps "$base_extras" pr/litellm/proxy-extras/pyproject.toml "litellm/proxy-extras/pyproject.toml" || had_error=1
check_deps "$base_enterprise" pr/litellm/enterprise/pyproject.toml "litellm/enterprise/pyproject.toml" || had_error=1
if [ "$had_error" -ne 0 ]; then if [ "$had_error" -ne 0 ]; then
echo "" echo ""

View file

@ -7,30 +7,35 @@ on:
- litellm_oss_branch - litellm_oss_branch
- "litellm_**" - "litellm_**"
paths: paths:
- litellm/deploy/docker/Dockerfile - Dockerfile
- litellm/deploy/docker/Dockerfile.dockerignore - docker/Dockerfile.non_root
- litellm/deploy/docker/docker-entrypoint.sh - docker/Dockerfile.database
- litellm/runtime/migrations/run.py - migrations/Dockerfile
- litellm/runtime/gateway/main.py - migrations/run.py
- litellm/runtime/gateway/launch.py - gateway/Dockerfile
- litellm/runtime/gateway/routes/allowlist.py - gateway/launch.py
- litellm/cli/** - gateway/main.py
- .github/packaging/** - gateway/routes/allowlist.py
- litellm/runtime/backend/main.py - litellm_cli/**
- litellm/runtime/backend/routes/allowlist.py - backend/Dockerfile
- litellm/rust/** - backend/main.py
- litellm-rust/**
- litellm/proxy/lens/** - litellm/proxy/lens/**
- docker/component_entrypoint.sh
- docker/entrypoint.sh
- litellm/proxy/prisma_migration.py - litellm/proxy/prisma_migration.py
- litellm/proxy/admin_mcp.py - litellm/proxy/admin_mcp.py
- litellm/proxy/proxy_server.py - litellm/proxy/proxy_server.py
- backend/routes/allowlist.py
- pyproject.toml - pyproject.toml
- litellm/proxy-extras/** - litellm-proxy-extras/**
- tests/integration/database/image/** - tests/integration/database/image/**
- uv.lock - uv.lock
- litellm/ui/litellm-dashboard/package-lock.json - ui/litellm-dashboard/package-lock.json
- litellm/ui/nginx.conf - ui/Dockerfile
- ui/nginx.conf
- .github/workflows/image-scan.yml - .github/workflows/image-scan.yml
- .github/grype.yaml - .grype.yaml
schedule: schedule:
- cron: "41 6 * * *" - cron: "41 6 * * *"
workflow_dispatch: workflow_dispatch:
@ -41,13 +46,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
env:
IMAGE: litellm-image-scan:${{ github.sha }}
ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }}
jobs: jobs:
build: image-scan:
name: build name: image-scan
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: >- if: >-
github.event_name != 'pull_request' || github.event_name != 'pull_request' ||
@ -60,129 +61,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Build the image
run: docker build -f litellm/deploy/docker/Dockerfile --build-arg MATURIN_PEP517_ARGS="--profile ci" --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci -t "$IMAGE" .
- name: Save the image for the verify matrix
run: docker save "$IMAGE" | zstd -T0 -3 -o "$RUNNER_TEMP/litellm-image.tar.zst"
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: litellm-image
path: ${{ runner.temp }}/litellm-image.tar.zst
retention-days: 1
compression-level: 0
- name: Build the Admin MCP schema image
run: docker build -f litellm/deploy/docker/Dockerfile --build-arg MATURIN_PEP517_ARGS="--profile ci" --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci --target builder -t "$ADMIN_MCP_SCHEMA_IMAGE" .
- name: Save the Admin MCP schema image for the verify matrix
run: docker save "$ADMIN_MCP_SCHEMA_IMAGE" | zstd -T0 -3 -o "$RUNNER_TEMP/litellm-admin-mcp-schema.tar.zst"
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: litellm-admin-mcp-schema
path: ${{ runner.temp }}/litellm-admin-mcp-schema.tar.zst
retention-days: 1
compression-level: 0
verify:
name: verify (${{ matrix.check }})
needs: build
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
strategy:
fail-fast: false
matrix:
check: [scan, migrations, gateway, backend, ui, collector]
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: litellm-image
path: ${{ runner.temp }}
- name: Load the image
run: zstd -d --stdout "$RUNNER_TEMP/litellm-image.tar.zst" | docker load
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: matrix.check == 'migrations' || matrix.check == 'backend'
with:
name: litellm-admin-mcp-schema
path: ${{ runner.temp }}
- name: Load the Admin MCP schema image
if: matrix.check == 'migrations' || matrix.check == 'backend'
run: zstd -d --stdout "$RUNNER_TEMP/litellm-admin-mcp-schema.tar.zst" | docker load
- name: Set up Python
if: matrix.check != 'scan'
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Install pytest
if: matrix.check != 'scan'
run: python -m pip install "pytest==9.0.3"
# The prisma bake must migrate a fresh DB with no egress as an arbitrary
# non-root uid (OpenShift restricted-v2 / air-gapped / readOnlyFilesystem).
# `docker run` as the default uid with network hides a broken bake because
# the migration entrypoint exits 0 even when it applied nothing; asserting
# the schema was created is what catches it.
- name: Verify offline migration as a non-root uid
if: matrix.check == 'migrations'
env:
LITELLM_IMAGE: ${{ env.IMAGE }}
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: ${{ env.ADMIN_MCP_SCHEMA_IMAGE }}
run: |
python -m pytest --noconftest -v \
tests/integration/database/image/test_offline_image_migration.py \
tests/integration/database/image/test_image_bedrock_realtime_extra.py \
tests/integration/database/image/test_image_admin_mcp.py
- name: Verify the gateway component serves offline as a non-root uid
if: matrix.check == 'gateway'
env:
LITELLM_IMAGE: ${{ env.IMAGE }}
LITELLM_COMPONENT: gateway
LITELLM_IMAGE_COMPONENT: gateway
run: |
python -m pytest --noconftest -v \
tests/integration/database/image/test_component_image_serves_offline.py \
tests/integration/database/image/test_image_admin_mcp.py
- name: Verify the backend component serves offline as a non-root uid
if: matrix.check == 'backend'
env:
LITELLM_IMAGE: ${{ env.IMAGE }}
LITELLM_COMPONENT: backend
LITELLM_IMAGE_COMPONENT: backend
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: ${{ env.ADMIN_MCP_SCHEMA_IMAGE }}
run: |
python -m pytest --noconftest -v \
tests/integration/database/image/test_component_image_serves_offline.py \
tests/integration/database/image/test_image_admin_mcp.py
- name: Verify the ui component serves as an arbitrary uid with a read-only root fs
if: matrix.check == 'ui'
env:
LITELLM_IMAGE: ${{ env.IMAGE }}
run: python -m pytest --noconftest -v tests/integration/database/image/test_ui_image_serves_offline.py
- name: Verify the collector creates its socket as the image user
if: matrix.check == 'collector'
env:
LITELLM_IMAGE: ${{ env.IMAGE }}
run: python -m pytest --noconftest -v tests/integration/database/image/test_collector_image_serves_socket.py
- name: Download Grype v0.114.0 - name: Download Grype v0.114.0
if: matrix.check == 'scan'
run: | run: |
curl -fsSL --retry 3 -o "$RUNNER_TEMP/grype.tar.gz" \ curl -fsSL --retry 3 -o "$RUNNER_TEMP/grype.tar.gz" \
https://github.com/anchore/grype/releases/download/v0.114.0/grype_0.114.0_linux_amd64.tar.gz https://github.com/anchore/grype/releases/download/v0.114.0/grype_0.114.0_linux_amd64.tar.gz
@ -190,6 +69,33 @@ jobs:
tar xzf "$RUNNER_TEMP/grype.tar.gz" -C "$RUNNER_TEMP" grype tar xzf "$RUNNER_TEMP/grype.tar.gz" -C "$RUNNER_TEMP" grype
chmod +x "$RUNNER_TEMP/grype" chmod +x "$RUNNER_TEMP/grype"
# Dockerfile.non_root is the rootless variant we ship. The other
# Dockerfiles share the same wolfi base and apk set, so OS-layer coverage
# is the same; matrix-scan if those variants ever diverge.
- name: Build runtime image
run: docker build -f docker/Dockerfile.non_root -t litellm-image-scan:${{ github.sha }} .
- name: Tag the cached builder for Admin MCP schema setup
run: docker build --target builder -f docker/Dockerfile.non_root -t litellm-admin-mcp-schema:${{ github.sha }} .
# The prisma bake must migrate a fresh DB with no egress as an arbitrary
# non-root uid (OpenShift restricted-v2 / air-gapped / readOnlyRootFilesystem).
# `docker run` as the default uid with network hides a broken bake because
# the migration entrypoint exits 0 even when it applied nothing; asserting
# the schema was created is what catches it.
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify offline migration as a non-root uid
env:
LITELLM_IMAGE: litellm-image-scan:${{ github.sha }}
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
# Scans the whole shipped artifact: OS/apk plus every language package # Scans the whole shipped artifact: OS/apk plus every language package
# baked into the image, including ones no lockfile declares (e.g. prisma's # baked into the image, including ones no lockfile declares (e.g. prisma's
# vendored node engine) that osv-scan cannot see. osv-scan stays the fast # vendored node engine) that osv-scan cannot see. osv-scan stays the fast
@ -197,12 +103,175 @@ jobs:
# free OSS, run as a pinned, checksum-verified binary; no GitHub Action # free OSS, run as a pinned, checksum-verified binary; no GitHub Action
# dependency and no vendor SaaS callout. # dependency and no vendor SaaS callout.
- name: Scan image for fixable HIGH/CRITICAL CVEs - name: Scan image for fixable HIGH/CRITICAL CVEs
if: matrix.check == 'scan'
env: env:
GRYPE_MATCH_PYTHON_USING_CPES: "true" GRYPE_MATCH_PYTHON_USING_CPES: "true"
run: | run: |
"$RUNNER_TEMP/grype" "$IMAGE" \ "$RUNNER_TEMP/grype" litellm-image-scan:${{ github.sha }} \
--config .github/grype.yaml \ --config .grype.yaml \
--only-fixed \ --only-fixed \
--fail-on high \ --fail-on high \
--output table --output table
runtime-image:
name: runtime-image (${{ matrix.dockerfile }})
runs-on: ubuntu-latest
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 45
permissions:
contents: read
strategy:
fail-fast: false
matrix:
dockerfile: [Dockerfile, docker/Dockerfile.database]
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Build runtime image
run: docker build --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci -f "${{ matrix.dockerfile }}" -t litellm-runtime-scan:${{ github.sha }} .
- name: Tag the cached builder for Admin MCP schema setup
run: docker build --target builder -f "${{ matrix.dockerfile }}" -t litellm-admin-mcp-schema:${{ github.sha }} .
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify offline migration as a non-root uid
env:
LITELLM_IMAGE: litellm-runtime-scan:${{ github.sha }}
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
migrations-image:
name: migrations-image
runs-on: ubuntu-latest
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Build migrations image
run: docker build -f migrations/Dockerfile -t litellm-migrations-scan:${{ github.sha }} .
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify offline migration as a non-root uid
env:
LITELLM_IMAGE: litellm-migrations-scan:${{ github.sha }}
LITELLM_MIGRATION_INTERPRETER: python3
LITELLM_MIGRATION_SCRIPT: /app/run.py
run: |
python -m pip install "pytest==9.0.3"
python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py -v
gateway-image:
name: gateway-image
runs-on: ubuntu-latest
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Build gateway image
run: docker build -f gateway/Dockerfile -t litellm-gateway-scan:${{ github.sha }} .
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify the gateway serves offline as a non-root uid
env:
LITELLM_IMAGE: litellm-gateway-scan:${{ github.sha }}
LITELLM_COMPONENT_PORT: "4000"
LITELLM_IMAGE_COMPONENT: gateway
run: |
python -m pip install "pytest==9.0.3"
python -m pytest --noconftest tests/integration/database/image/test_component_image_serves_offline.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
ui-image:
name: ui-image
runs-on: ubuntu-latest
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Build UI image
run: docker build -f ui/Dockerfile -t litellm-ui-scan:${{ github.sha }} .
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify the UI serves offline as an arbitrary uid with a read-only root fs
env:
LITELLM_IMAGE: litellm-ui-scan:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
python -m pytest --noconftest tests/integration/database/image/test_ui_image_serves_offline.py -v
backend-image:
name: backend-image
runs-on: ubuntu-latest
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Build backend image
run: docker build -f backend/Dockerfile -t litellm-backend-scan:${{ github.sha }} .
- name: Tag the cached builder for Admin MCP schema setup
run: docker build --target builder -f backend/Dockerfile -t litellm-admin-mcp-schema:${{ github.sha }} .
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify the backend serves offline as a non-root uid
env:
LITELLM_IMAGE: litellm-backend-scan:${{ github.sha }}
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }}
LITELLM_COMPONENT_PORT: "4001"
LITELLM_IMAGE_COMPONENT: backend
run: |
python -m pip install "pytest==9.0.3"
python -m pytest --noconftest tests/integration/database/image/test_component_image_serves_offline.py tests/integration/database/image/test_image_admin_mcp.py -v

View file

@ -16,12 +16,12 @@ on:
- .github/issue-labels.json - .github/issue-labels.json
- .github/ISSUE_TEMPLATE/bug_report.yml - .github/ISSUE_TEMPLATE/bug_report.yml
- .github/ISSUE_TEMPLATE/feature_request.yml - .github/ISSUE_TEMPLATE/feature_request.yml
- litellm/scripts/classify-issue.ts - scripts/classify-issue.ts
- litellm/scripts/classify-issue.test.ts - scripts/classify-issue.test.ts
- litellm/scripts/label-issue.ts - scripts/label-issue.ts
- litellm/scripts/label-issue.test.ts - scripts/label-issue.test.ts
- litellm/scripts/issue-labels.ts - scripts/issue-labels.ts
- litellm/scripts/auto-close-duplicates.ts - scripts/auto-close-duplicates.ts
permissions: {} permissions: {}
@ -49,7 +49,7 @@ jobs:
bun-version: "1.4.0" bun-version: "1.4.0"
- name: Test the gate, the validation and the label step - name: Test the gate, the validation and the label step
run: bun test litellm/scripts/classify-issue.test.ts litellm/scripts/label-issue.test.ts run: bun test scripts/classify-issue.test.ts scripts/label-issue.test.ts
classify-issue: classify-issue:
# An edit to a labelled issue is dropped here; the script decides the rest against the live labels # An edit to a labelled issue is dropped here; the script decides the rest against the live labels
@ -73,7 +73,7 @@ jobs:
with: with:
sparse-checkout: | sparse-checkout: |
.github .github
litellm/scripts scripts
persist-credentials: false persist-credentials: false
- name: Setup Bun - name: Setup Bun
@ -108,7 +108,7 @@ jobs:
ISSUE_CLASSIFIER_MODEL: ${{ vars.ISSUE_CLASSIFIER_MODEL }} ISSUE_CLASSIFIER_MODEL: ${{ vars.ISSUE_CLASSIFIER_MODEL }}
run: | run: |
set -euo pipefail set -euo pipefail
bun run litellm/scripts/classify-issue.ts > classification.json bun run scripts/classify-issue.ts > classification.json
{ {
echo 'verdict<<CLASSIFICATION' echo 'verdict<<CLASSIFICATION'
cat classification.json cat classification.json
@ -143,7 +143,7 @@ jobs:
with: with:
sparse-checkout: | sparse-checkout: |
.github .github
litellm/scripts scripts
persist-credentials: false persist-credentials: false
- name: Setup Bun - name: Setup Bun
@ -153,7 +153,7 @@ jobs:
bun-version: "1.4.0" bun-version: "1.4.0"
- name: Replace the labels in each namespace - name: Replace the labels in each namespace
run: bun run litellm/scripts/label-issue.ts run: bun run scripts/label-issue.ts
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERDICT: ${{ needs.classify-issue.outputs.verdict }} VERDICT: ${{ needs.classify-issue.outputs.verdict }}

View file

@ -15,9 +15,9 @@ on:
pull_request: pull_request:
paths: paths:
- .github/workflows/issue_fixed_comment.yml - .github/workflows/issue_fixed_comment.yml
- litellm/scripts/comment-fixed-issue.ts - scripts/comment-fixed-issue.ts
- litellm/scripts/comment-fixed-issue.test.ts - scripts/comment-fixed-issue.test.ts
- litellm/scripts/auto-close-duplicates.ts - scripts/auto-close-duplicates.ts
permissions: {} permissions: {}
@ -44,7 +44,7 @@ jobs:
bun-version: "1.4.0" bun-version: "1.4.0"
- name: Test the closer lookup, the release placement, the comment and the superseded pull request close - name: Test the closer lookup, the release placement, the comment and the superseded pull request close
run: bun test litellm/scripts/comment-fixed-issue.test.ts run: bun test scripts/comment-fixed-issue.test.ts
comment-fixed-issue: comment-fixed-issue:
if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm' if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm'
@ -58,7 +58,7 @@ jobs:
- name: Checkout scripts - name: Checkout scripts
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:
sparse-checkout: litellm/scripts sparse-checkout: scripts
persist-credentials: false persist-credentials: false
- name: Setup Bun - name: Setup Bun
@ -69,7 +69,7 @@ jobs:
- name: Name the release that carries the fix and close the pull requests it supersedes - name: Name the release that carries the fix and close the pull requests it supersedes
shell: bash shell: bash
run: bun run litellm/scripts/comment-fixed-issue.ts | tee -a "${GITHUB_STEP_SUMMARY}" run: bun run scripts/comment-fixed-issue.ts | tee -a "${GITHUB_STEP_SUMMARY}"
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }} ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }}

View file

@ -5,7 +5,7 @@ on:
branches: [main] branches: [main]
paths: paths:
- .github/issue-labels.json - .github/issue-labels.json
- litellm/scripts/sync-issue-labels.ts - scripts/sync-issue-labels.ts
workflow_dispatch: workflow_dispatch:
inputs: inputs:
dry_run: dry_run:
@ -16,9 +16,9 @@ on:
paths: paths:
- .github/workflows/issue_label_sync.yml - .github/workflows/issue_label_sync.yml
- .github/issue-labels.json - .github/issue-labels.json
- litellm/scripts/sync-issue-labels.ts - scripts/sync-issue-labels.ts
- litellm/scripts/sync-issue-labels.test.ts - scripts/sync-issue-labels.test.ts
- litellm/scripts/issue-labels.ts - scripts/issue-labels.ts
permissions: {} permissions: {}
@ -41,7 +41,7 @@ jobs:
bun-version: "1.4.0" bun-version: "1.4.0"
- name: Test the sync - name: Test the sync
run: bun test litellm/scripts/sync-issue-labels.test.ts run: bun test scripts/sync-issue-labels.test.ts
sync-issue-labels: sync-issue-labels:
if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm' if: github.event_name != 'pull_request' && github.repository == 'BerriAI/litellm'
@ -56,7 +56,7 @@ jobs:
with: with:
sparse-checkout: | sparse-checkout: |
.github .github
litellm/scripts scripts
persist-credentials: false persist-credentials: false
- name: Setup Bun - name: Setup Bun
@ -66,7 +66,7 @@ jobs:
bun-version: "1.4.0" bun-version: "1.4.0"
- name: Create or recolour every label in .github/issue-labels.json - name: Create or recolour every label in .github/issue-labels.json
run: bun run litellm/scripts/sync-issue-labels.ts run: bun run scripts/sync-issue-labels.ts
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }}

View file

@ -129,7 +129,7 @@ jobs:
set +e set +e
uv run --no-sync --with mutmut==3.5.0 mutmut export-cicd-stats > /dev/null 2>&1 uv run --no-sync --with mutmut==3.5.0 mutmut export-cicd-stats > /dev/null 2>&1
uv run --no-sync --with mutmut==3.5.0 mutmut results > mutmut-results.txt 2>&1 uv run --no-sync --with mutmut==3.5.0 mutmut results > mutmut-results.txt 2>&1
uv run --no-sync python litellm/scripts/mutation_report.py uv run --no-sync python scripts/mutation_report.py
# The full report can be very long for big test files; the run-page # The full report can be very long for big test files; the run-page
# summary cuts off at 1 MB. Append the head of the report (summary # summary cuts off at 1 MB. Append the head of the report (summary
# + survivor list) and link out to the artifact for the full body. # + survivor list) and link out to the artifact for the full body.

View file

@ -40,7 +40,7 @@ jobs:
- name: Scan lockfiles - name: Scan lockfiles
run: | run: |
"$RUNNER_TEMP/osv-scanner" scan source \ "$RUNNER_TEMP/osv-scanner" scan source \
--config .github/osv-scanner.toml \ --config osv-scanner.toml \
-L uv.lock \ -L uv.lock \
-L litellm/ui/litellm-dashboard/package-lock.json \ -L ui/litellm-dashboard/package-lock.json \
-L litellm/vscode-extension/package-lock.json -L vscode-extension/package-lock.json

View file

@ -3,7 +3,7 @@ name: Publish lint base counts
# Every commit on main can become a future merge-base. # Every commit on main can become a future merge-base.
# Publishing its per-rule counts for each lint gate (strict ruff, type discipline, # Publishing its per-rule counts for each lint gate (strict ruff, type discipline,
# test quality, basedpyright) as an artifact lets the gates download them through # test quality, basedpyright) as an artifact lets the gates download them through
# litellm/scripts/lint_base_counts.py in seconds instead of scanning the merge-base tree in # scripts/lint_base_counts.py in seconds instead of scanning the merge-base tree in
# a throwaway worktree on every fresh checkout or moved merge-base. # a throwaway worktree on every fresh checkout or moved merge-base.
# No concurrency group on purpose: runs must never cancel each other, because # No concurrency group on purpose: runs must never cancel each other, because
# every sha's artifact matters (any of them can become a merge-base). # every sha's artifact matters (any of them can become a merge-base).
@ -30,15 +30,15 @@ jobs:
matrix: matrix:
include: include:
- checker: ruff-strict - checker: ruff-strict
script: litellm/scripts/ruff_strict_gate.py script: scripts/ruff_strict_gate.py
- checker: type-discipline - checker: type-discipline
script: litellm/scripts/type_discipline_gate.py script: scripts/type_discipline_gate.py
- checker: test-quality - checker: test-quality
script: litellm/scripts/test_quality_gate.py script: scripts/test_quality_gate.py
- checker: openapi-docs - checker: openapi-docs
script: litellm/scripts/openapi_docs_gate.py script: scripts/openapi_docs_gate.py
- checker: basedpyright - checker: basedpyright
script: litellm/scripts/type_check_gate.py script: scripts/type_check_gate.py
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
@ -91,7 +91,7 @@ jobs:
- name: Emit basedpyright counts for HEAD - name: Emit basedpyright counts for HEAD
if: matrix.checker == 'basedpyright' if: matrix.checker == 'basedpyright'
run: | run: |
python litellm/scripts/type_check_gate.py --emit-counts-dir "$RUNNER_TEMP/lint-counts" python scripts/type_check_gate.py --emit-counts-dir "$RUNNER_TEMP/lint-counts"
- name: Name the counts artifact - name: Name the counts artifact
run: | run: |

View file

@ -1,27 +0,0 @@
name: Root entry limit
on:
pull_request: {}
push:
branches: [main]
permissions:
contents: read
jobs:
root-entry-limit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955
with:
persist-credentials: false
- name: Check root entry count
env:
MAX_ROOT_ENTRIES: 15
run: |
count=$(find . -mindepth 1 -maxdepth 1 -printf '.\n' | wc -l)
echo "Root has $count entries. Maximum is $MAX_ROOT_ENTRIES."
if [ "$count" -gt "$MAX_ROOT_ENTRIES" ]; then
echo "::error::Repository root exceeds $MAX_ROOT_ENTRIES entries. Move new files into an existing directory."
exit 1
fi

View file

@ -42,12 +42,4 @@ jobs:
MERGE_BASE: ${{ steps.revisions.outputs.merge_base }} MERGE_BASE: ${{ steps.revisions.outputs.merge_base }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
guard=litellm/scripts/ci_cd/spend_logs_guard.py uv run --no-project --with pydantic==2.14.1 python ci_cd/spend_logs_guard.py --base "$MERGE_BASE" --head "$HEAD_SHA" --approver kerry-berri --reviews "$RUNNER_TEMP/reviews.json"
# TODO: Drop the legacy path after this lands on main.
if [ ! -f "$guard" ]; then
guard=scripts/ci_cd/spend_logs_guard.py
fi
if [ ! -f "$guard" ]; then
guard=ci_cd/spend_logs_guard.py
fi
uv run --no-project --with pydantic==2.14.1 python "$guard" --base "$MERGE_BASE" --head "$HEAD_SHA" --approver kerry-berri --reviews "$RUNNER_TEMP/reviews.json"

View file

@ -1,9 +1,9 @@
name: Sync litellm/schema.prisma copies name: Sync schema.prisma copies
on: on:
pull_request: pull_request:
paths: paths:
- 'litellm/schema.prisma' - 'schema.prisma'
# Scoped to ONLY the permissions needed: # Scoped to ONLY the permissions needed:
# - contents:write to push the sync commit to the PR branch # - contents:write to push the sync commit to the PR branch
@ -37,7 +37,7 @@ jobs:
- name: Reject symlinked schema files - name: Reject symlinked schema files
run: | run: |
for f in litellm/schema.prisma litellm/proxy/schema.prisma litellm/proxy-extras/litellm_proxy_extras/schema.prisma; do for f in schema.prisma litellm/proxy/schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma; do
if [ -L "$f" ]; then if [ -L "$f" ]; then
echo "::error file=$f::$f is a symlink, which is not allowed" echo "::error file=$f::$f is a symlink, which is not allowed"
exit 1 exit 1
@ -46,13 +46,13 @@ jobs:
- name: Copy root schema to other locations - name: Copy root schema to other locations
run: | run: |
cp litellm/schema.prisma litellm/proxy/schema.prisma cp schema.prisma litellm/proxy/schema.prisma
cp litellm/schema.prisma litellm/proxy-extras/litellm_proxy_extras/schema.prisma cp schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma
- name: Check for changes - name: Check for changes
id: diff id: diff
run: | run: |
if git diff --quiet -- litellm/proxy/schema.prisma litellm/proxy-extras/litellm_proxy_extras/schema.prisma; then if git diff --quiet -- litellm/proxy/schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma; then
echo "changed=false" >> "$GITHUB_OUTPUT" echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Schemas already in sync. Nothing to do." echo "Schemas already in sync. Nothing to do."
else else
@ -68,6 +68,6 @@ jobs:
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$GITHUB_HEAD_REF" git checkout -B "$GITHUB_HEAD_REF"
git add -- litellm/proxy/schema.prisma litellm/proxy-extras/litellm_proxy_extras/schema.prisma git add -- litellm/proxy/schema.prisma litellm-proxy-extras/litellm_proxy_extras/schema.prisma
git commit -m "chore: sync litellm/schema.prisma copies from the canonical schema" git commit -m "chore: sync schema.prisma copies from root"
git push origin "HEAD:$GITHUB_HEAD_REF" git push origin "HEAD:$GITHUB_HEAD_REF"

View file

@ -55,6 +55,7 @@ jobs:
services: services:
postgres: postgres:
image: postgres:16@sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5 image: postgres:16@sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5
command: -c max_connections=1000 -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c pg_stat_statements.max=20000
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
@ -80,17 +81,6 @@ jobs:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:
persist-credentials: false persist-credentials: false
- name: Configure PostgreSQL for integration workers
env:
POSTGRES_CONTAINER: ${{ job.services.postgres.id }}
run: |
docker exec "$POSTGRES_CONTAINER" sh -c 'printf "%s\n" "max_connections = 1000" "shared_preload_libraries = pg_stat_statements" "pg_stat_statements.track = all" "pg_stat_statements.max = 20000" >> "$PGDATA/postgresql.conf"'
docker restart "$POSTGRES_CONTAINER"
for attempt in $(seq 1 30); do
if docker exec "$POSTGRES_CONTAINER" pg_isready -U postgres; then exit 0; fi
sleep 1
done
exit 1
- name: Set up Python - name: Set up Python
timeout-minutes: 3 timeout-minutes: 3
@ -116,17 +106,6 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
run: .github/scripts/uv_sync_with_retries.sh --frozen --all-groups --all-extras run: .github/scripts/uv_sync_with_retries.sh --frozen --all-groups --all-extras
- name: Install the Rust gateway
if: matrix.suite == 'management' || matrix.suite == 'management-replica'
timeout-minutes: 15
working-directory: litellm/packaging/litellm-proxy
env:
CARGO_INCREMENTAL: "0"
run: |
uv run --no-project --with maturin==1.15.0 maturin build --profile dev --strip --out "$RUNNER_TEMP/gateway-dist"
uv pip install --no-deps --python ../../../.venv/bin/python "$RUNNER_TEMP"/gateway-dist/*.whl
test -x ../../../.venv/bin/litellm-gateway
- name: Install redis-server 7.2.16 - name: Install redis-server 7.2.16
timeout-minutes: 3 timeout-minutes: 3
run: | run: |
@ -150,22 +129,22 @@ jobs:
timeout-minutes: 5 timeout-minutes: 5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with: with:
node-version-file: litellm/ui/litellm-dashboard/.nvmrc node-version-file: ui/litellm-dashboard/.nvmrc
cache: "npm" cache: "npm"
cache-dependency-path: | cache-dependency-path: |
litellm/ui/litellm-dashboard/package-lock.json ui/litellm-dashboard/package-lock.json
tests/e2e/ui/package-lock.json tests/e2e/ui/package-lock.json
- name: Install browser dependencies and build the dashboard - name: Install browser dependencies and build the dashboard
if: matrix.suite == 'browser' if: matrix.suite == 'browser'
timeout-minutes: 20 timeout-minutes: 20
run: | run: |
(cd litellm/ui/litellm-dashboard && npm ci) (cd ui/litellm-dashboard && npm ci)
(cd tests/e2e/ui && npm ci) (cd tests/e2e/ui && npm ci)
sudo env PATH="$PATH" DEBIAN_FRONTEND=noninteractive \ sudo env PATH="$PATH" DEBIAN_FRONTEND=noninteractive \
node tests/e2e/ui/node_modules/@playwright/test/cli.js install-deps chromium node tests/e2e/ui/node_modules/@playwright/test/cli.js install-deps chromium
node tests/e2e/ui/node_modules/@playwright/test/cli.js install chromium node tests/e2e/ui/node_modules/@playwright/test/cli.js install chromium
(cd litellm/ui/litellm-dashboard && NEXT_TELEMETRY_DISABLED=1 npm run build) (cd ui/litellm-dashboard && NEXT_TELEMETRY_DISABLED=1 npm run build)
- name: Run integration contracts - name: Run integration contracts
timeout-minutes: 45 timeout-minutes: 45
@ -176,8 +155,8 @@ jobs:
LITELLM_LICENSE: ${{ secrets.LITELLM_LICENSE }} LITELLM_LICENSE: ${{ secrets.LITELLM_LICENSE }}
run: | run: |
case "$SUITE" in case "$SUITE" in
*-replica) bash .github/scripts/ci/run_integration.sh "${SUITE%-replica}" replica ;; *-replica) bash .circleci/scripts/run_integration.sh "${SUITE%-replica}" replica ;;
*) bash .github/scripts/ci/run_integration.sh "$SUITE" ;; *) bash .circleci/scripts/run_integration.sh "$SUITE" ;;
esac esac
- name: Verify every case ran exactly once - name: Verify every case ran exactly once

View file

@ -73,14 +73,14 @@ jobs:
- name: Cache uv dependencies - name: Cache uv dependencies
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with: with:
path: | path: |
~/.cache/uv ~/.cache/uv
.venv .venv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }} key: ${{ runner.os }}-uv-lint-${{ hashFiles('uv.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-uv- ${{ runner.os }}-uv-lint-
- name: Clean Python cache - name: Clean Python cache
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
@ -114,7 +114,7 @@ jobs:
- name: Check ruff format - name: Check ruff format
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
run: | run: |
git diff --name-only --diff-filter=ACMR "$GATE_BASE_SHA" HEAD -- ':(glob)litellm/**/*.py' | grep -Ev '^litellm/(enterprise|ui|vscode-extension|rust|proxy-extras|packaging|cli|scripts|runtime)/' > "$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 if [ ! -s "$RUNNER_TEMP/ruff_format_files.txt" ]; then
echo "No changed litellm Python files to check with ruff format." echo "No changed litellm Python files to check with ruff format."
exit 0 exit 0
@ -133,7 +133,7 @@ jobs:
- name: Check MCP operation boundary - name: Check MCP operation boundary
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
run: uv run --no-sync python litellm/scripts/check_mcp_operation_boundary.py run: uv run --no-sync python scripts/check_mcp_operation_boundary.py
- name: Run Ruff linting - name: Run Ruff linting
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
@ -145,28 +145,28 @@ jobs:
- name: Run Ruff linting (test tree) - name: Run Ruff linting (test tree)
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
run: | run: |
uv run --no-sync ruff check --config .github/linters/ruff-tests.toml tests uv run --no-sync ruff check --config ruff-tests.toml tests
- name: Check strict ruff rules (delta vs merge-base counts) - name: Check strict ruff rules (delta vs merge-base counts)
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
uv run --no-sync python litellm/scripts/ruff_strict_gate.py --base "$GATE_BASE_SHA" uv run --no-sync python scripts/ruff_strict_gate.py --base "$GATE_BASE_SHA"
- name: Check type discipline (mutable collections / casts / type guards / kwargs / unexplained suppressions, delta vs merge-base counts) - name: Check type discipline (mutable collections / casts / type guards / kwargs / unexplained suppressions, delta vs merge-base counts)
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
uv run --no-sync python litellm/scripts/type_discipline_gate.py --base "$GATE_BASE_SHA" uv run --no-sync python scripts/type_discipline_gate.py --base "$GATE_BASE_SHA"
- name: Check test quality (zero-assert / mock-echo tests, sys.path.insert, raw env writes, litellm global mutation, credential-gated skips, conftest snapshot inventory, delta vs merge-base counts) - name: Check test quality (zero-assert / mock-echo tests, sys.path.insert, raw env writes, litellm global mutation, credential-gated skips, conftest snapshot inventory, delta vs merge-base counts)
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
uv run --no-sync python litellm/scripts/test_quality_gate.py --base "$GATE_BASE_SHA" uv run --no-sync python scripts/test_quality_gate.py --base "$GATE_BASE_SHA"
- name: Check OpenAPI docs (POST routes without a request schema, description or 200 schema, delta vs merge-base counts) - name: Check OpenAPI docs (POST routes without a request schema, description or 200 schema, delta vs merge-base counts)
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
@ -174,7 +174,7 @@ jobs:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
uv sync --frozen --group proxy-dev --group e2e-dev --extra proxy uv sync --frozen --group proxy-dev --group e2e-dev --extra proxy
uv run --no-sync python litellm/scripts/openapi_docs_gate.py --base "$GATE_BASE_SHA" uv run --no-sync python scripts/openapi_docs_gate.py --base "$GATE_BASE_SHA"
- name: Print OpenAI version - name: Print OpenAI version
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
@ -186,12 +186,12 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
uv run --no-sync python litellm/scripts/type_check_gate.py --base "$GATE_BASE_SHA" uv run --no-sync python scripts/type_check_gate.py --base "$GATE_BASE_SHA"
- name: Check tests/e2e basedpyright (zero errors) - name: Check tests/e2e basedpyright (zero errors)
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
run: | run: |
if git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- ':(glob)tests/e2e/**/*.py' ':(glob)tests/e2e_harness/**/*.py' pyproject.toml | grep -q .; then if git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- ':(glob)tests/e2e/**/*.py' ':(glob)tests/e2e_harness/**/*.py' pyrightconfig.json | grep -q .; then
uv run --no-sync basedpyright tests/e2e tests/e2e_harness uv run --no-sync basedpyright tests/e2e tests/e2e_harness
else else
echo "No changed tests/e2e Python files; skipping." echo "No changed tests/e2e Python files; skipping."
@ -258,7 +258,7 @@ jobs:
if [ -n "$GITGUARDIAN_API_KEY" ]; then if [ -n "$GITGUARDIAN_API_KEY" ]; then
retry() { "$@" || { sleep 15; "$@"; } || { sleep 30; "$@"; }; } retry() { "$@" || { sleep 15; "$@"; } || { sleep 30; "$@"; }; }
retry git fetch --no-tags --unshallow origin retry git fetch --no-tags --unshallow origin
uv tool run --from 'ggshield==1.48.0' ggshield --config-path .github/gitguardian.yaml secret scan repo . uv tool run --from 'ggshield==1.48.0' ggshield secret scan repo .
else else
echo "GITGUARDIAN_API_KEY not set, skipping ggshield scan" echo "GITGUARDIAN_API_KEY not set, skipping ggshield scan"
fi fi
@ -271,7 +271,7 @@ jobs:
timeout-minutes: 8 timeout-minutes: 8
defaults: defaults:
run: run:
working-directory: litellm/ui/litellm-dashboard working-directory: ui/litellm-dashboard
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -315,9 +315,9 @@ jobs:
if: steps.changed.outputs.has_files == 'true' if: steps.changed.outputs.has_files == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with: with:
node-version-file: litellm/ui/litellm-dashboard/.nvmrc node-version-file: ui/litellm-dashboard/.nvmrc
cache: "npm" cache: "npm"
cache-dependency-path: litellm/ui/litellm-dashboard/package-lock.json cache-dependency-path: ui/litellm-dashboard/package-lock.json
- name: Install dependencies - name: Install dependencies
if: steps.changed.outputs.has_files == 'true' if: steps.changed.outputs.has_files == 'true'
@ -333,12 +333,12 @@ jobs:
status=0 status=0
if [ ${#prettier_files[@]} -gt 0 ]; then if [ ${#prettier_files[@]} -gt 0 ]; then
echo "::group::Prettier (${#prettier_files[@]} files)" echo "::group::Prettier (${#prettier_files[@]} files)"
node_modules/.bin/prettier --check "${prettier_files[@]}" || { status=1; echo "::error::Unformatted files. Fix with: npm run format"; } npx prettier --check "${prettier_files[@]}" || { status=1; echo "::error::Unformatted files. Fix with: npm run format"; }
echo "::endgroup::" echo "::endgroup::"
fi fi
if [ ${#eslint_files[@]} -gt 0 ]; then if [ ${#eslint_files[@]} -gt 0 ]; then
echo "::group::ESLint (${#eslint_files[@]} files)" echo "::group::ESLint (${#eslint_files[@]} files)"
node_modules/.bin/eslint --no-warn-ignored --pass-on-unpruned-suppressions "${eslint_files[@]}" || status=1 npx eslint --no-warn-ignored --pass-on-unpruned-suppressions "${eslint_files[@]}" || status=1
echo "::endgroup::" echo "::endgroup::"
fi fi
exit $status exit $status
@ -375,7 +375,7 @@ jobs:
exit 0 exit 0
fi fi
files="$(git diff --name-only "$base" HEAD)" files="$(git diff --name-only "$base" HEAD)"
if grep -Ev '^litellm/(ui|vscode-extension|rust|proxy-extras|packaging|cli|scripts|runtime)/' <<< "$files" | grep -E '^(litellm/|enterprise/|(pyproject\.toml|uv\.lock)$|\.github/workflows/test-linting\.yml$)' >/dev/null || grep -Eq '^litellm/ui/litellm-dashboard/(src/lib/http/schema\.d\.ts|scripts/gen-api-types\.mjs|package(-lock)?\.json)$' <<< "$files"; then if grep -Eq '^(litellm/|enterprise/|(pyproject\.toml|uv\.lock)$|ui/litellm-dashboard/(src/lib/http/schema\.d\.ts|scripts/gen-api-types\.mjs|package(-lock)?\.json)$|\.github/workflows/test-linting\.yml$)' <<< "$files"; then
echo "relevant=true" >> "$GITHUB_OUTPUT" echo "relevant=true" >> "$GITHUB_OUTPUT"
else else
echo "No litellm, enterprise, dependency or generator changes in this pull request, nothing to verify." echo "No litellm, enterprise, dependency or generator changes in this pull request, nothing to verify."
@ -396,7 +396,7 @@ jobs:
- name: Cache uv dependencies - name: Cache uv dependencies
if: steps.changes.outputs.relevant == 'true' if: steps.changes.outputs.relevant == 'true'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with: with:
path: | path: |
~/.cache/uv ~/.cache/uv
@ -434,7 +434,7 @@ jobs:
echo "A lazily loaded route or model changed without regenerating the snapshot that /openapi.json serves for unloaded features." echo "A lazily loaded route or model changed without regenerating the snapshot that /openapi.json serves for unloaded features."
echo "To fix, run from the repo root:" echo "To fix, run from the repo root:"
echo " uv run python -m litellm.proxy._lazy_openapi_snapshot" echo " uv run python -m litellm.proxy._lazy_openapi_snapshot"
echo "then run npm run gen:api from litellm/ui/litellm-dashboard and commit both files." echo "then run npm run gen:api from ui/litellm-dashboard and commit both files."
exit 1 exit 1
fi fi
echo "_lazy_openapi_snapshot.json is in sync with the lazily loaded routes." echo "_lazy_openapi_snapshot.json is in sync with the lazily loaded routes."
@ -443,18 +443,18 @@ jobs:
if: steps.changes.outputs.relevant == 'true' if: steps.changes.outputs.relevant == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with: with:
node-version-file: litellm/ui/litellm-dashboard/.nvmrc node-version-file: ui/litellm-dashboard/.nvmrc
cache: "npm" cache: "npm"
cache-dependency-path: litellm/ui/litellm-dashboard/package-lock.json cache-dependency-path: ui/litellm-dashboard/package-lock.json
- name: Install dashboard dependencies - name: Install dashboard dependencies
if: steps.changes.outputs.relevant == 'true' if: steps.changes.outputs.relevant == 'true'
working-directory: litellm/ui/litellm-dashboard working-directory: ui/litellm-dashboard
run: npm ci run: npm ci
- name: Regenerate types from the live spec - name: Regenerate types from the live spec
if: steps.changes.outputs.relevant == 'true' if: steps.changes.outputs.relevant == 'true'
working-directory: litellm/ui/litellm-dashboard working-directory: ui/litellm-dashboard
env: env:
LITELLM_PYTHON: "uv run --no-sync python" LITELLM_PYTHON: "uv run --no-sync python"
run: npm run gen:api run: npm run gen:api
@ -462,11 +462,11 @@ jobs:
- name: Fail if types are stale - name: Fail if types are stale
if: steps.changes.outputs.relevant == 'true' if: steps.changes.outputs.relevant == 'true'
run: | run: |
if ! git diff --exit-code -- litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts; then if ! git diff --exit-code -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
echo "::error file=litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts::Generated API types are out of sync with the proxy OpenAPI spec." echo "::error file=ui/litellm-dashboard/src/lib/http/schema.d.ts::Generated API types are out of sync with the proxy OpenAPI spec."
echo "" echo ""
echo "A backend route or model changed without regenerating the dashboard types." echo "A backend route or model changed without regenerating the dashboard types."
echo "To fix, run from litellm/ui/litellm-dashboard:" echo "To fix, run from ui/litellm-dashboard:"
echo " npm run gen:api" echo " npm run gen:api"
echo "then commit the updated src/lib/http/schema.d.ts." echo "then commit the updated src/lib/http/schema.d.ts."
exit 1 exit 1
@ -524,7 +524,7 @@ jobs:
run: uv run --no-sync python ./tests/code_coverage_tests/check_provider_folders_documented.py run: uv run --no-sync python ./tests/code_coverage_tests/check_provider_folders_documented.py
- name: check_provider_endpoints_support_generated - name: check_provider_endpoints_support_generated
run: uv run --no-sync python litellm/scripts/generate_provider_endpoints_support.py --check run: uv run --no-sync python scripts/generate_provider_endpoints_support.py --check
- name: test_generate_provider_endpoints_support - name: test_generate_provider_endpoints_support
run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_generate_provider_endpoints_support.py run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_generate_provider_endpoints_support.py
@ -703,7 +703,7 @@ jobs:
version: "0.10.9" version: "0.10.9"
- name: Run Semgrep (custom rules) - name: Run Semgrep (custom rules)
run: uv tool run --from 'semgrep==1.157.0' semgrep scan --config .github/semgrep/rules . --error run: uv tool run --from 'semgrep==1.157.0' semgrep scan --config .semgrep/rules . --error
lint-passed: lint-passed:
name: lint passed name: lint passed
needs: [python, secret-scan, ui, ui-api-types, code-quality, ci-coverage, semgrep] needs: [python, secret-scan, ui, ui-api-types, code-quality, ci-coverage, semgrep]

View file

@ -10,17 +10,17 @@ on:
- "uv.lock" - "uv.lock"
- "uv.toml" - "uv.toml"
- ".python-version" - ".python-version"
- "litellm/rust/rust-toolchain.toml" - "rust-toolchain.toml"
- "litellm/rust/**" - "litellm-rust/**"
- "litellm/__init__.py" - "litellm/__init__.py"
- "litellm/_version.py" - "litellm/_version.py"
- "litellm/_lazy_imports_registry.py" - "litellm/_lazy_imports_registry.py"
- "litellm/scripts/build_core_distribution.py" - "scripts/build_core_distribution.py"
- "litellm/proxy/proxy_server.py" - "litellm/proxy/proxy_server.py"
- "litellm/**/*mcp*" - "litellm/**/*mcp*"
- "litellm/**/*mcp*/**" - "litellm/**/*mcp*/**"
- "litellm/integrations/arize/**" - "litellm/integrations/arize/**"
- "litellm/scripts/check_mcp_sdk_install.py" - "scripts/check_mcp_sdk_install.py"
- "tests/base_sdk_tests/**" - "tests/base_sdk_tests/**"
- ".github/workflows/test-mcp-dependency-resolution.yml" - ".github/workflows/test-mcp-dependency-resolution.yml"
- ".github/actions/detect-changes/**" - ".github/actions/detect-changes/**"
@ -28,7 +28,7 @@ on:
- ".github/actions/cache-cargo-build/**" - ".github/actions/cache-cargo-build/**"
- ".github/scripts/detect_changes.sh" - ".github/scripts/detect_changes.sh"
- ".github/scripts/uv_sync_with_retries.sh" - ".github/scripts/uv_sync_with_retries.sh"
- ".github/scripts/ci/classify_changes.sh" - ".circleci/scripts/classify_changes.sh"
permissions: permissions:
contents: read contents: read
@ -38,16 +38,6 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# These jobs check packaging, installation and imports, so they build the Rust
# workspace unoptimized. The release profile's fat LTO and opt-level 3 pushed
# every job to its timeout. The overrides go through Cargo's environment
# because a maturin `--profile ci` argument also reaches dependencies that
# build from source, such as jiter on Python 3.15, which have no ci profile
env:
CARGO_PROFILE_RELEASE_OPT_LEVEL: "0"
CARGO_PROFILE_RELEASE_LTO: "off"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256"
jobs: jobs:
resolve: resolve:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -101,7 +91,7 @@ jobs:
checker=("$GITHUB_WORKSPACE/tests/base_sdk_tests/check_base_sdk_install.py") checker=("$GITHUB_WORKSPACE/tests/base_sdk_tests/check_base_sdk_install.py")
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-$extra/bin/python" -I "$GITHUB_WORKSPACE/tests/base_sdk_tests/check_sdk_http.py") (cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-$extra/bin/python" -I "$GITHUB_WORKSPACE/tests/base_sdk_tests/check_sdk_http.py")
else else
checker=("$GITHUB_WORKSPACE/litellm/scripts/check_mcp_sdk_install.py" --extra "$extra") checker=("$GITHUB_WORKSPACE/scripts/check_mcp_sdk_install.py" --extra "$extra")
fi fi
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-$extra/bin/python" "${checker[@]}") (cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-$extra/bin/python" "${checker[@]}")
done done
@ -125,7 +115,7 @@ jobs:
if [ "$extra" = core ]; then if [ "$extra" = core ]; then
checker=("$GITHUB_WORKSPACE/tests/base_sdk_tests/check_base_sdk_install.py") checker=("$GITHUB_WORKSPACE/tests/base_sdk_tests/check_base_sdk_install.py")
else else
checker=("$GITHUB_WORKSPACE/litellm/scripts/check_mcp_sdk_install.py" --extra "$extra") checker=("$GITHUB_WORKSPACE/scripts/check_mcp_sdk_install.py" --extra "$extra")
fi fi
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-lowest-$extra/bin/python" "${checker[@]}") (cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-lowest-$extra/bin/python" "${checker[@]}")
done done
@ -157,16 +147,15 @@ jobs:
- name: Build the core wheel and source distribution - name: Build the core wheel and source distribution
run: >- run: >-
uv run --no-project --with coverage==7.14.0 python -m coverage run --rcfile=/dev/null uv run --no-project --with coverage==7.14.0 python -m coverage run --rcfile=/dev/null
--branch --include="*/litellm/scripts/build_core_distribution.py" --branch --include="*/scripts/build_core_distribution.py"
litellm/scripts/build_core_distribution.py --out-dir dist/core scripts/build_core_distribution.py --out-dir dist/core
- name: Verify metadata, resources, and independent source rebuild - name: Verify metadata, resources, and independent source rebuild
env: env:
CORE_DISTRIBUTION_DIR: ${{ github.workspace }}/dist/core CORE_DISTRIBUTION_DIR: ${{ github.workspace }}/dist/core
run: >- run: >-
uv run --no-project --with pytest==9.0.3 --with pytest-cov==5.0.0 --with coverage==7.14.0 --with 'tomli==2.4.1; python_version < "3.11"' uv run --no-project --with pytest==9.0.3 --with pytest-cov==5.0.0 --with coverage==7.14.0 --with 'tomli==2.4.1; python_version < "3.11"'
python -m coverage run --rcfile=/dev/null --append --branch python -m pytest tests/base_sdk_tests/test_core_distribution.py -v
--include="*/litellm/scripts/build_core_distribution.py" --cov-config=/dev/null --cov=scripts.build_core_distribution --cov-branch --cov-append --cov-report=
-m pytest tests/base_sdk_tests/test_core_distribution.py -v
- name: Verify isolated core installations - name: Verify isolated core installations
run: | run: |
wheel=$(realpath dist/core/litellm_core-*.whl) wheel=$(realpath dist/core/litellm_core-*.whl)

View file

@ -3,8 +3,6 @@ name: smoke
on: on:
pull_request: pull_request:
branches: [main, litellm_internal_staging, litellm_oss_staging, "litellm_**"] branches: [main, litellm_internal_staging, litellm_oss_staging, "litellm_**"]
push:
branches: [main]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -26,7 +24,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Build the dashboard stage - name: Build the dashboard stage
run: docker build --target ui-builder -f litellm/deploy/docker/Dockerfile . run: docker build --target ui-builder -f Dockerfile .
core-checks: core-checks:
name: py${{ matrix.python-version }} name: py${{ matrix.python-version }}
@ -54,26 +52,8 @@ jobs:
with: with:
version: "0.10.9" version: "0.10.9"
- name: Restore the Rust bridge
id: rust-bridge
uses: ./.github/actions/restore-rust-bridge
- name: Restore the Cargo build cache
if: steps.rust-bridge.outputs.cache-hit != 'true'
uses: ./.github/actions/cache-cargo-build
- name: Install dependencies - name: Install dependencies
env: run: .github/scripts/uv_sync_with_retries.sh --frozen --extra proxy --extra cli --group dev --group proxy-dev --python ${{ matrix.python-version }}
RUST_BRIDGE_RESTORED: ${{ steps.rust-bridge.outputs.cache-hit }}
PYTHON_VERSION: ${{ matrix.python-version }}
run: |
if [ "$RUST_BRIDGE_RESTORED" != "true" ]; then
.github/scripts/uv_sync_with_retries.sh --frozen --extra proxy --extra cli --group dev --group proxy-dev --python "$PYTHON_VERSION"
exit 0
fi
.github/scripts/uv_sync_with_retries.sh --frozen --extra proxy --extra cli --group dev --group proxy-dev --python "$PYTHON_VERSION" --no-install-project
uv pip install --no-deps --python .venv/bin/python rust-bridge-dist/litellm-*.whl
cp rust-bridge-dist/litellm/rust_bridge/_native.abi3.so litellm/rust_bridge/_native.abi3.so
- name: Create the loopback-only network namespace - name: Create the loopback-only network namespace
run: | run: |
@ -107,7 +87,6 @@ jobs:
- name: Upload smoke diagnostics - name: Upload smoke diagnostics
if: always() if: always()
continue-on-error: true
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with: with:
name: merge-smoke-diagnostics-py${{ matrix.python-version }} name: merge-smoke-diagnostics-py${{ matrix.python-version }}
@ -118,10 +97,6 @@ jobs:
if: always() if: always()
run: sudo ip netns delete smoke run: sudo ip netns delete smoke
- name: Keep only source-built wheels in the uv cache
if: always() && github.ref == 'refs/heads/main'
run: uv cache prune --ci
all-extras: all-extras:
name: all-extras-py${{ matrix.python-version }} name: all-extras-py${{ matrix.python-version }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@ -148,37 +123,14 @@ jobs:
with: with:
version: "0.10.9" version: "0.10.9"
- name: Restore the Rust bridge
id: rust-bridge
uses: ./.github/actions/restore-rust-bridge
- name: Restore the Cargo build cache
if: steps.rust-bridge.outputs.cache-hit != 'true'
uses: ./.github/actions/cache-cargo-build
- name: Install every extra and dependency group - name: Install every extra and dependency group
env: run: .github/scripts/uv_sync_with_retries.sh --frozen --all-groups --all-extras --python ${{ matrix.python-version }}
RUST_BRIDGE_RESTORED: ${{ steps.rust-bridge.outputs.cache-hit }}
PYTHON_VERSION: ${{ matrix.python-version }}
run: |
if [ "$RUST_BRIDGE_RESTORED" != "true" ]; then
.github/scripts/uv_sync_with_retries.sh --frozen --all-groups --all-extras --python "$PYTHON_VERSION"
exit 0
fi
.github/scripts/uv_sync_with_retries.sh --frozen --all-groups --all-extras --python "$PYTHON_VERSION" --no-install-project --no-install-package litellm-proxy
uv pip install --no-deps --python .venv/bin/python rust-bridge-dist/*.whl
cp rust-bridge-dist/litellm/rust_bridge/_native.abi3.so litellm/rust_bridge/_native.abi3.so
- name: Import the SDK, the proxy server and the enterprise package - name: Import the SDK, the proxy server and the enterprise package
run: .venv/bin/python -c "import litellm, litellm.proxy.proxy_server, litellm_enterprise" run: .venv/bin/python -c "import litellm, litellm.proxy.proxy_server, litellm_enterprise"
- name: Keep only source-built wheels in the uv cache
if: always() && github.ref == 'refs/heads/main'
run: uv cache prune --ci
windows-sdk: windows-sdk:
name: windows-sdk name: windows-sdk
if: github.event_name != 'pull_request'
runs-on: windows-2025 runs-on: windows-2025
timeout-minutes: 30 timeout-minutes: 30
env: env:
@ -202,19 +154,17 @@ jobs:
with: with:
version: "0.10.9" version: "0.10.9"
- name: Set up the Rust bridge
uses: ./.github/actions/rust-bridge
- name: Install dependencies - name: Install dependencies
run: .github/scripts/uv_sync_with_retries.sh --frozen --group dev --python 3.11 run: .github/scripts/uv_sync_with_retries.sh --frozen --group dev --python 3.11
- name: Run the Windows tests - name: Run the Windows tests
run: uv run --no-sync python -m pytest --tb=short -v tests/windows_tests run: uv run --no-sync python -m pytest --tb=short -v tests/windows_tests
- name: Keep only source-built wheels in the uv cache
if: always() && github.ref == 'refs/heads/main'
run: uv cache prune --ci
proxy-build-from-pip: proxy-build-from-pip:
name: proxy-build-from-pip name: proxy-build-from-pip
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 10 timeout-minutes: 10
services: services:
@ -250,36 +200,8 @@ jobs:
- name: Install the test client dependencies - name: Install the test client dependencies
run: .github/scripts/uv_sync_with_retries.sh --frozen --no-install-workspace --extra proxy --group dev --python 3.12 run: .github/scripts/uv_sync_with_retries.sh --frozen --no-install-workspace --extra proxy --group dev --python 3.12
- name: Cache Prisma binaries - name: Build the image from the published package
timeout-minutes: 3 run: docker build -t litellm-build-from-pip:smoke -f docker/build_from_pip/Dockerfile.build_from_pip .
uses: ./.github/actions/cache-prisma-binaries
- name: Install the published proxy into its own venv
run: |
uv --no-config venv --python 3.13 /tmp/litellm-pip
uv --no-config pip install --python /tmp/litellm-pip/bin/python \
"litellm[proxy,proxy-runtime]==1.83.0" \
"google-cloud-aiplatform==1.133.0" \
"google-genai==1.37.0" \
"anthropic[vertex]==0.84.0" \
"grpcio==1.78.0" \
"prometheus-client==0.20.0" \
"langfuse==2.59.7" \
"opentelemetry-api==1.28.0" \
"opentelemetry-sdk==1.28.0" \
"opentelemetry-exporter-otlp==1.28.0" \
"ddtrace==4.11.0" \
"sentry-sdk==2.21.0" \
"mangum==0.17.0" \
"azure-ai-contentsafety==1.0.0" \
"azure-storage-file-datalake==12.20.0" \
"pypdf==6.7.5" \
"llm-sandbox==0.3.31" \
"detect-secrets==1.5.0" \
"prisma==0.11.0" \
"openai==2.24.0"
export PATH="/tmp/litellm-pip/bin:$PATH"
/tmp/litellm-pip/bin/prisma generate --schema=litellm/schema.prisma
- name: Start the fake OpenAI endpoint - name: Start the fake OpenAI endpoint
run: | run: |
@ -297,21 +219,23 @@ jobs:
echo "::add-mask::${key}" echo "::add-mask::${key}"
echo "LITELLM_MASTER_KEY=${key}" >> "${GITHUB_ENV}" echo "LITELLM_MASTER_KEY=${key}" >> "${GITHUB_ENV}"
- name: Start the published proxy - name: Start the proxy container
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/circle_test
LITELLM_LICENSE: bad-license
FAKE_OPENAI_API_BASE: http://localhost:8190
OTEL_EXPORTER: in_memory
USE_DDTRACE: "True"
DD_TRACE_OPENAI_ENABLED: "False"
GCS_FLUSH_INTERVAL: "1"
LITELLM_LOG: ERROR
run: | run: |
cd /tmp/litellm-pip docker run -d \
nohup /tmp/litellm-pip/bin/ddtrace-run /tmp/litellm-pip/bin/litellm \ -p 4000:4000 \
--config "${GITHUB_WORKSPACE}/tests/basic_proxy_startup_tests/build_from_pip_config.yaml" \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
--port 4000 > /tmp/litellm-pip.log 2>&1 & -e LITELLM_MASTER_KEY \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e OTEL_EXPORTER=in_memory \
-e USE_DDTRACE=True \
-e GCS_FLUSH_INTERVAL=1 \
-e LITELLM_LOG=ERROR \
--add-host host.docker.internal:host-gateway \
--name litellm-build-from-pip \
-v "${GITHUB_WORKSPACE}/docker/build_from_pip/litellm_config.yaml:/app/config.yaml" \
litellm-build-from-pip:smoke \
--config /app/config.yaml \
--port 4000
for _ in $(seq 1 150); do for _ in $(seq 1 150); do
curl -sf --max-time 5 -o /dev/null http://localhost:4000 && exit 0 curl -sf --max-time 5 -o /dev/null http://localhost:4000 && exit 0
sleep 2 sleep 2
@ -346,15 +270,11 @@ jobs:
- name: Show the proxy logs - name: Show the proxy logs
if: always() if: always()
run: cat /tmp/litellm-pip.log || true run: docker logs litellm-build-from-pip || true
- name: Keep only source-built wheels in the uv cache
if: always() && github.ref == 'refs/heads/main'
run: uv cache prune --ci
smoke-passed: smoke-passed:
name: smoke passed name: smoke passed
needs: [dashboard-build, core-checks, all-extras] needs: [dashboard-build, core-checks, all-extras, windows-sdk, proxy-build-from-pip]
if: always() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 2 timeout-minutes: 2

View file

@ -3,14 +3,14 @@ name: LiteLLM Rust DB
on: on:
push: push:
paths: paths:
- "litellm/rust/crates/db/**" - "litellm-rust/crates/db/**"
- "litellm/rust/Cargo.toml" - "litellm-rust/Cargo.toml"
- "litellm/rust/Cargo.lock" - "litellm-rust/Cargo.lock"
- "litellm/rust/clippy.toml" - "litellm-rust/clippy.toml"
- "litellm/proxy-extras/litellm_proxy_extras/migrations/**" - "litellm-proxy-extras/litellm_proxy_extras/migrations/**"
- "**/schema.prisma" - "**/schema.prisma"
- "litellm/rust/.cargo/**" - ".cargo/**"
- "litellm/rust/rust-toolchain.toml" - "rust-toolchain.toml"
- "pyproject.toml" - "pyproject.toml"
- "uv.lock" - "uv.lock"
- ".github/actions/setup-uv-with-retries/**" - ".github/actions/setup-uv-with-retries/**"
@ -20,14 +20,14 @@ on:
- main - main
- "litellm_**" - "litellm_**"
paths: paths:
- "litellm/rust/crates/db/**" - "litellm-rust/crates/db/**"
- "litellm/rust/Cargo.toml" - "litellm-rust/Cargo.toml"
- "litellm/rust/Cargo.lock" - "litellm-rust/Cargo.lock"
- "litellm/rust/clippy.toml" - "litellm-rust/clippy.toml"
- "litellm/proxy-extras/litellm_proxy_extras/migrations/**" - "litellm-proxy-extras/litellm_proxy_extras/migrations/**"
- "**/schema.prisma" - "**/schema.prisma"
- "litellm/rust/.cargo/**" - ".cargo/**"
- "litellm/rust/rust-toolchain.toml" - "rust-toolchain.toml"
- "pyproject.toml" - "pyproject.toml"
- "uv.lock" - "uv.lock"
- ".github/actions/setup-uv-with-retries/**" - ".github/actions/setup-uv-with-retries/**"
@ -42,6 +42,10 @@ concurrency:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: litellm-ci-v1
SCCACHE_GHA_RW_MODE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'READ_WRITE' || 'READ_ONLY' }}
CARGO_INCREMENTAL: "0" CARGO_INCREMENTAL: "0"
jobs: jobs:
@ -67,7 +71,7 @@ jobs:
SQLX_OFFLINE: "false" SQLX_OFFLINE: "false"
defaults: defaults:
run: run:
working-directory: litellm/rust working-directory: litellm-rust
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:
@ -85,25 +89,27 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with: with:
workspaces: litellm/rust workspaces: litellm-rust
cache-on-failure: true cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up sccache
uses: mozilla-actions/sccache-action@d651010b8da762cde178750d8eda7b5febfe147a
with:
version: "v0.18.0"
- name: Install Python dependencies - name: Install Python dependencies
working-directory: . working-directory: .
env:
SQLX_OFFLINE: "true"
run: uv sync --frozen --extra extra-proxy run: uv sync --frozen --extra extra-proxy
- name: Deploy Prisma migrations - name: Deploy Prisma migrations
working-directory: . working-directory: .
run: uv run --no-sync prisma migrate deploy --schema litellm/proxy-extras/litellm_proxy_extras/schema.prisma run: uv run --no-sync prisma migrate deploy --schema litellm-proxy-extras/litellm_proxy_extras/schema.prisma
- name: Install SQLx CLI - name: Install SQLx CLI
run: cargo install sqlx-cli --version 0.9.0 --locked --no-default-features -F postgres run: cargo install sqlx-cli --version 0.9.0 --locked --no-default-features -F postgres
- name: Check SQLx query metadata - name: Check SQLx query metadata
working-directory: litellm/rust/crates/db working-directory: litellm-rust/crates/db
run: cargo sqlx prepare --check run: cargo sqlx prepare --check
- name: Test database queries - name: Test database queries

View file

@ -3,12 +3,12 @@ name: LiteLLM Rust
on: on:
push: push:
paths: paths:
- "litellm/rust/**" - "litellm-rust/**"
- "model_prices_and_context_window.json" - "model_prices_and_context_window.json"
- "litellm/rust_bridge/**" - "litellm/rust_bridge/**"
- "litellm/scripts/generate_trace_types.py" - "scripts/generate_trace_types.py"
- "litellm/scripts/trace_codegen/**" - "scripts/trace_codegen/**"
- "litellm/scripts/lens_assets/**" - "scripts/lens_assets/**"
- "litellm/tracing/**" - "litellm/tracing/**"
- "tests/test_litellm_rust/**" - "tests/test_litellm_rust/**"
- "litellm/integrations/custom_logger.py" - "litellm/integrations/custom_logger.py"
@ -24,10 +24,10 @@ on:
- "litellm/llms/base_llm/ocr/**" - "litellm/llms/base_llm/ocr/**"
- "litellm/llms/custom_httpx/llm_http_handler.py" - "litellm/llms/custom_httpx/llm_http_handler.py"
- "Makefile" - "Makefile"
- "litellm/rust/.cargo/**" - ".cargo/**"
- "pyproject.toml" - "pyproject.toml"
- "uv.lock" - "uv.lock"
- "litellm/rust/rust-toolchain.toml" - "rust-toolchain.toml"
- ".github/actions/setup-uv-with-retries/**" - ".github/actions/setup-uv-with-retries/**"
- ".github/scripts/smoke_test_native_wheel.py" - ".github/scripts/smoke_test_native_wheel.py"
- ".github/scripts/verify_linux_native_wheel.py" - ".github/scripts/verify_linux_native_wheel.py"
@ -38,12 +38,12 @@ on:
- main - main
- "litellm_**" - "litellm_**"
paths: paths:
- "litellm/rust/**" - "litellm-rust/**"
- "model_prices_and_context_window.json" - "model_prices_and_context_window.json"
- "litellm/rust_bridge/**" - "litellm/rust_bridge/**"
- "litellm/scripts/generate_trace_types.py" - "scripts/generate_trace_types.py"
- "litellm/scripts/trace_codegen/**" - "scripts/trace_codegen/**"
- "litellm/scripts/lens_assets/**" - "scripts/lens_assets/**"
- "litellm/tracing/**" - "litellm/tracing/**"
- "tests/test_litellm_rust/**" - "tests/test_litellm_rust/**"
- "litellm/integrations/custom_logger.py" - "litellm/integrations/custom_logger.py"
@ -59,10 +59,10 @@ on:
- "litellm/llms/base_llm/ocr/**" - "litellm/llms/base_llm/ocr/**"
- "litellm/llms/custom_httpx/llm_http_handler.py" - "litellm/llms/custom_httpx/llm_http_handler.py"
- "Makefile" - "Makefile"
- "litellm/rust/.cargo/**" - ".cargo/**"
- "pyproject.toml" - "pyproject.toml"
- "uv.lock" - "uv.lock"
- "litellm/rust/rust-toolchain.toml" - "rust-toolchain.toml"
- ".github/actions/setup-uv-with-retries/**" - ".github/actions/setup-uv-with-retries/**"
- ".github/scripts/smoke_test_native_wheel.py" - ".github/scripts/smoke_test_native_wheel.py"
- ".github/scripts/verify_linux_native_wheel.py" - ".github/scripts/verify_linux_native_wheel.py"
@ -78,6 +78,10 @@ concurrency:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: litellm-ci-v1
SCCACHE_GHA_RW_MODE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'READ_WRITE' || 'READ_ONLY' }}
CARGO_INCREMENTAL: "0" CARGO_INCREMENTAL: "0"
jobs: jobs:
@ -86,24 +90,27 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
defaults: defaults:
run: run:
working-directory: litellm/rust working-directory: litellm-rust
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:
persist-credentials: false persist-credentials: false
- name: Configure the Rust build environment - run: rustup toolchain install --no-self-update
working-directory: .
run: bash .github/scripts/setup_rust_environment.sh
- run: cargo fmt --all --check - run: cargo fmt --all --check
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with: with:
workspaces: litellm/rust workspaces: litellm-rust
cache-on-failure: true cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up sccache
uses: mozilla-actions/sccache-action@d651010b8da762cde178750d8eda7b5febfe147a
with:
version: "v0.18.0"
- run: cargo clippy --workspace --all-targets --locked -- -D warnings - run: cargo clippy --workspace --all-targets --locked -- -D warnings
rust-test: rust-test:
@ -114,7 +121,7 @@ jobs:
CARGO_PROFILE_DEV_DEBUG: line-tables-only CARGO_PROFILE_DEV_DEBUG: line-tables-only
defaults: defaults:
run: run:
working-directory: litellm/rust working-directory: litellm-rust
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:
@ -142,13 +149,18 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with: with:
workspaces: litellm/rust workspaces: litellm-rust
cache-on-failure: true cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up sccache
uses: mozilla-actions/sccache-action@d651010b8da762cde178750d8eda7b5febfe147a
with:
version: "v0.18.0"
- name: Check generated trace contracts - name: Check generated trace contracts
working-directory: . working-directory: .
run: uv run litellm/scripts/generate_trace_types.py --check run: uv run scripts/generate_trace_types.py --check
- run: cargo nextest run --workspace --locked - run: cargo nextest run --workspace --locked
@ -188,10 +200,15 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with: with:
workspaces: litellm/rust workspaces: litellm-rust
cache-on-failure: true cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up sccache
uses: mozilla-actions/sccache-action@d651010b8da762cde178750d8eda7b5febfe147a
with:
version: "v0.18.0"
- run: uv build --wheel --out-dir dist - run: uv build --wheel --out-dir dist
- run: python .github/scripts/verify_linux_native_wheel.py dist/*.whl - run: python .github/scripts/verify_linux_native_wheel.py dist/*.whl

View file

@ -3,16 +3,16 @@ name: Terraform Modules
on: on:
push: push:
paths: paths:
- "litellm/deploy/terraform/litellm/aws/**" - "terraform/litellm/aws/**"
- "litellm/deploy/terraform/litellm/gcp/**" - "terraform/litellm/gcp/**"
- ".github/workflows/test-terraform-modules.yml" - ".github/workflows/test-terraform-modules.yml"
pull_request: pull_request:
branches: branches:
- main - main
- "litellm_**" - "litellm_**"
paths: paths:
- "litellm/deploy/terraform/litellm/aws/**" - "terraform/litellm/aws/**"
- "litellm/deploy/terraform/litellm/gcp/**" - "terraform/litellm/gcp/**"
- ".github/workflows/test-terraform-modules.yml" - ".github/workflows/test-terraform-modules.yml"
permissions: permissions:
@ -33,7 +33,7 @@ jobs:
module: [aws, gcp] module: [aws, gcp]
defaults: defaults:
run: run:
working-directory: litellm/deploy/terraform/litellm/${{ matrix.module }} working-directory: terraform/litellm/${{ matrix.module }}
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:

View file

@ -3,18 +3,16 @@ name: Terraform Provider
on: on:
push: push:
paths: paths:
- "litellm/deploy/terraform/provider/**" - "terraform/provider/**"
- ".github/actions/cache-go-build/**"
- ".github/workflows/test-terraform-provider.yml" - ".github/workflows/test-terraform-provider.yml"
pull_request: pull_request:
branches: branches:
- main - main
- "litellm_**" - "litellm_**"
paths: paths:
- "litellm/deploy/terraform/provider/**" - "terraform/provider/**"
- "litellm/proxy/**" - "litellm/proxy/**"
- "litellm/enterprise/**" - "enterprise/**"
- ".github/actions/cache-go-build/**"
- ".github/workflows/test-terraform-provider.yml" - ".github/workflows/test-terraform-provider.yml"
permissions: permissions:
@ -31,7 +29,7 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
defaults: defaults:
run: run:
working-directory: litellm/deploy/terraform/provider working-directory: terraform/provider
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:
@ -39,13 +37,9 @@ jobs:
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with: with:
go-version-file: litellm/deploy/terraform/provider/go.mod go-version-file: terraform/provider/go.mod
cache: false cache: true
cache-dependency-path: terraform/provider/go.sum
- name: Cache the Go build
uses: ./.github/actions/cache-go-build
with:
dependency-path: litellm/deploy/terraform/provider/go.sum
- name: gofmt - name: gofmt
run: | run: |
@ -121,18 +115,14 @@ jobs:
- name: Generate proxy OpenAPI schema - name: Generate proxy OpenAPI schema
run: | run: |
uv run --no-sync python litellm/deploy/terraform/provider/tools/dump_openapi.py "${RUNNER_TEMP}/openapi.json" uv run --no-sync python terraform/provider/tools/dump_openapi.py "${RUNNER_TEMP}/openapi.json"
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with: with:
go-version-file: litellm/deploy/terraform/provider/go.mod go-version-file: terraform/provider/go.mod
cache: false cache: true
cache-dependency-path: terraform/provider/go.sum
- name: Cache the Go build
uses: ./.github/actions/cache-go-build
with:
dependency-path: litellm/deploy/terraform/provider/go.sum
- name: Audit provider endpoints against the schema - name: Audit provider endpoints against the schema
working-directory: litellm/deploy/terraform/provider working-directory: terraform/provider
run: go run ./tools/endpointaudit -provider-dir ./litellm -spec "${RUNNER_TEMP}/openapi.json" -coverage-allowlist ./tools/endpointaudit/coverage_allowlist.txt run: go run ./tools/endpointaudit -provider-dir ./litellm -spec "${RUNNER_TEMP}/openapi.json" -coverage-allowlist ./tools/endpointaudit/coverage_allowlist.txt

View file

@ -41,6 +41,13 @@ jobs:
timeout-minutes: 2 timeout-minutes: 2
uses: ./.github/actions/detect-changes uses: ./.github/actions/detect-changes
- name: Define editable Rust bridge cache key
id: rust-bridge-key
if: steps.changes.outputs.decision != 'skip'
env:
RUST_BRIDGE_CACHE_KEY: ${{ runner.os }}-rust-bridge-editable-${{ hashFiles('litellm-rust/Cargo.lock', 'litellm-rust/Cargo.toml', 'litellm-rust/crates/**', 'model_prices_and_context_window.json', 'pyproject.toml', 'packaging/litellm-proxy/pyproject.toml') }}
run: echo "key=$RUST_BRIDGE_CACHE_KEY" >> "$GITHUB_OUTPUT"
- name: Set up Python - name: Set up Python
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
timeout-minutes: 3 timeout-minutes: 3
@ -59,21 +66,34 @@ jobs:
id: rust-bridge-cache id: rust-bridge-cache
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
timeout-minutes: 5 timeout-minutes: 5
uses: ./.github/actions/restore-rust-bridge uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: rust-bridge-dist
key: ${{ steps.rust-bridge-key.outputs.key }}
- name: Cache the Rust build - name: Cache the Rust build
if: steps.changes.outputs.decision != 'skip' && steps.rust-bridge-cache.outputs.cache-hit != 'true' if: steps.changes.outputs.decision != 'skip' && steps.rust-bridge-cache.outputs.cache-hit != 'true'
timeout-minutes: 5 timeout-minutes: 5
uses: ./.github/actions/cache-cargo-build uses: ./.github/actions/cache-cargo-build
- name: Set up sccache
if: steps.changes.outputs.decision != 'skip' && steps.rust-bridge-cache.outputs.cache-hit != 'true'
uses: mozilla-actions/sccache-action@d651010b8da762cde178750d8eda7b5febfe147a
with:
version: "v0.18.0"
- name: Build editable Rust bridge - name: Build editable Rust bridge
if: steps.changes.outputs.decision != 'skip' && steps.rust-bridge-cache.outputs.cache-hit != 'true' if: steps.changes.outputs.decision != 'skip' && steps.rust-bridge-cache.outputs.cache-hit != 'true'
timeout-minutes: 15 timeout-minutes: 15
env: env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: litellm-ci-v1
SCCACHE_GHA_RW_MODE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'READ_WRITE' || 'READ_ONLY' }}
CARGO_INCREMENTAL: "0" CARGO_INCREMENTAL: "0"
run: | run: |
mkdir -p rust-bridge-dist mkdir -p rust-bridge-dist
uv run --no-project --with maturin==1.15.0 python .github/packaging/build_backend.py rust-bridge-dist uv run --no-project --with maturin==1.15.0 python -c 'import maturin; maturin.build_editable("rust-bridge-dist")'
strip --strip-debug litellm/rust_bridge/_native.abi3.so strip --strip-debug litellm/rust_bridge/_native.abi3.so
mkdir -p rust-bridge-dist/litellm/rust_bridge mkdir -p rust-bridge-dist/litellm/rust_bridge
cp litellm/rust_bridge/_native.abi3.so rust-bridge-dist/litellm/rust_bridge/_native.abi3.so cp litellm/rust_bridge/_native.abi3.so rust-bridge-dist/litellm/rust_bridge/_native.abi3.so
@ -81,10 +101,14 @@ jobs:
- name: Build the gateway wheel once for test shards - name: Build the gateway wheel once for test shards
if: steps.changes.outputs.decision != 'skip' && steps.rust-bridge-cache.outputs.cache-hit != 'true' if: steps.changes.outputs.decision != 'skip' && steps.rust-bridge-cache.outputs.cache-hit != 'true'
timeout-minutes: 15 timeout-minutes: 15
working-directory: litellm/packaging/litellm-proxy working-directory: packaging/litellm-proxy
env: env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: litellm-ci-v1
SCCACHE_GHA_RW_MODE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'READ_WRITE' || 'READ_ONLY' }}
CARGO_INCREMENTAL: "0" CARGO_INCREMENTAL: "0"
run: uv run --no-project --with maturin==1.15.0 maturin build --profile dev --strip --out ../../../rust-bridge-dist run: uv run --no-project --with maturin==1.15.0 maturin build --profile dev --strip --out ../../rust-bridge-dist
- name: Upload Rust bridge artifact - name: Upload Rust bridge artifact
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
@ -101,7 +125,7 @@ jobs:
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830
with: with:
path: rust-bridge-dist path: rust-bridge-dist
key: ${{ steps.rust-bridge-cache.outputs.key }} key: ${{ steps.rust-bridge-key.outputs.key }}
- name: Expose the Rust bridge artifact - name: Expose the Rust bridge artifact
id: rust-bridge-artifact id: rust-bridge-artifact
@ -126,7 +150,7 @@ jobs:
name: ${{ matrix.shard }} name: ${{ matrix.shard }}
needs: [rust-bridge, assert-shard-coverage] needs: [rust-bridge, assert-shard-coverage]
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
runs-on: ${{ matrix.runner || 'ubuntu-latest' }} runs-on: ubuntu-latest
timeout-minutes: ${{ matrix.job-timeout-minutes }} timeout-minutes: ${{ matrix.job-timeout-minutes }}
permissions: permissions:
contents: read contents: read
@ -220,7 +244,6 @@ jobs:
dist: loadscope dist: loadscope
- shard: llms-providers - shard: llms-providers
runner: ubuntu-latest-16-cores
test-path: |- test-path: |-
tests/unit/llms/test_api_base_trailing_slash_routes.py tests/unit/llms/test_api_base_trailing_slash_routes.py
tests/unit/llms/test_cache_control_and_reasoning.py tests/unit/llms/test_cache_control_and_reasoning.py
@ -374,14 +397,13 @@ jobs:
tests/unit/llms/voyage tests/unit/llms/voyage
tests/unit/llms/wandb tests/unit/llms/wandb
tests/unit/llms/watsonx tests/unit/llms/watsonx
tests/unit/llms/webiq
tests/unit/llms/xai tests/unit/llms/xai
tests/unit/llms/xinference tests/unit/llms/xinference
tests/unit/llms/you_com tests/unit/llms/you_com
tests/unit/llms/zai tests/unit/llms/zai
--ignore=tests/unit/llms/base_llm/batches/base_batches_config_test.py --ignore=tests/unit/llms/base_llm/batches/base_batches_config_test.py
python-version: "3.12" python-version: "3.12"
workers: 8 workers: 4
reruns: 0 reruns: 0
timeout-minutes: 20 timeout-minutes: 20
job-timeout-minutes: 60 job-timeout-minutes: 60
@ -399,11 +421,10 @@ jobs:
dist: loadscope dist: loadscope
- shard: root - shard: root
runner: ubuntu-latest-16-cores
test-path: >- test-path: >-
tests/unit/test_*.py tests/unit/test_*.py
python-version: "3.12" python-version: "3.12"
workers: 8 workers: 4
reruns: 2 reruns: 2
timeout-minutes: 20 timeout-minutes: 20
job-timeout-minutes: 60 job-timeout-minutes: 60
@ -438,6 +459,7 @@ jobs:
tests/unit/ocr tests/unit/ocr
tests/unit/passthrough tests/unit/passthrough
tests/unit/rag tests/unit/rag
tests/unit/cookbook
tests/unit/realtime_api tests/unit/realtime_api
tests/unit/rerank_api tests/unit/rerank_api
tests/unit/search tests/unit/search
@ -524,13 +546,11 @@ jobs:
dist: loadscope dist: loadscope
- shard: proxy-endpoints - shard: proxy-endpoints
runner: ubuntu-latest-16-cores
test-path: |- test-path: |-
tests/unit/proxy/management_endpoints tests/unit/proxy/management_endpoints
tests/unit/proxy/management_helpers tests/unit/proxy/management_helpers
tests/unit/proxy/list_api tests/unit/proxy/list_api
tests/unit/proxy/analytics_endpoints tests/unit/proxy/analytics_endpoints
tests/unit/proxy/alerting_endpoints
tests/unit/proxy/decisions_endpoints tests/unit/proxy/decisions_endpoints
tests/unit/proxy/memory tests/unit/proxy/memory
tests/unit/proxy/agent_endpoints tests/unit/proxy/agent_endpoints
@ -542,14 +562,13 @@ jobs:
--ignore=tests/unit/proxy/management_endpoints/test_roi_calculator_endpoints.py --ignore=tests/unit/proxy/management_endpoints/test_roi_calculator_endpoints.py
--ignore=tests/unit/proxy/management_helpers/test_audit_logs_proxy.py --ignore=tests/unit/proxy/management_helpers/test_audit_logs_proxy.py
python-version: "3.12" python-version: "3.12"
workers: 8 workers: 4
reruns: 0 reruns: 0
timeout-minutes: 20 timeout-minutes: 20
job-timeout-minutes: 60 job-timeout-minutes: 60
dist: loadscope dist: loadscope
- shard: proxy-feature-endpoints - shard: proxy-feature-endpoints
runner: ubuntu-latest-16-cores
test-path: |- test-path: |-
tests/unit/proxy/guardrails tests/unit/proxy/guardrails
--ignore=tests/unit/proxy/google_endpoints/test_gemini_agents_endpoints.py --ignore=tests/unit/proxy/google_endpoints/test_gemini_agents_endpoints.py
@ -571,7 +590,6 @@ jobs:
tests/unit/proxy/credential_endpoints tests/unit/proxy/credential_endpoints
tests/unit/proxy/discovery_endpoints tests/unit/proxy/discovery_endpoints
tests/unit/proxy/shutdown tests/unit/proxy/shutdown
tests/unit/proxy/rust_forward
tests/unit/proxy/public_endpoints tests/unit/proxy/public_endpoints
tests/unit/proxy/prompts tests/unit/proxy/prompts
tests/unit/proxy/rag_endpoints tests/unit/proxy/rag_endpoints
@ -582,7 +600,7 @@ jobs:
tests/unit/proxy/config_resolvers tests/unit/proxy/config_resolvers
tests/unit/proxy/utils tests/unit/proxy/utils
python-version: "3.12" python-version: "3.12"
workers: 8 workers: 4
reruns: 0 reruns: 0
timeout-minutes: 20 timeout-minutes: 20
job-timeout-minutes: 60 job-timeout-minutes: 60
@ -614,32 +632,23 @@ jobs:
job-timeout-minutes: 60 job-timeout-minutes: 60
dist: loadscope dist: loadscope
- shard: proxy-infra-db-spend - shard: proxy-infra
test-path: |- test-path: |-
tests/unit/proxy/db tests/unit/proxy/db
--ignore=tests/unit/proxy/db/db_transaction_queue/test_e2e_pod_lock_manager.py --ignore=tests/unit/proxy/db/db_transaction_queue/test_e2e_pod_lock_manager.py
--ignore=tests/unit/proxy/db/test_update_daily_tag_spend.py --ignore=tests/unit/proxy/db/test_update_daily_tag_spend.py
tests/unit/proxy/middleware
--ignore=tests/unit/proxy/middleware/test_request_size_limit_middleware.py
tests/unit/proxy/spend_tracking tests/unit/proxy/spend_tracking
--ignore=tests/unit/proxy/spend_tracking/test_search_api_logging.py --ignore=tests/unit/proxy/spend_tracking/test_search_api_logging.py
tests/unit/proxy/pass_through_endpoints
tests/unit/proxy/mcp_server/test_tool_registry.py
tests/unit/proxy/common_utils tests/unit/proxy/common_utils
--ignore=tests/unit/proxy/common_utils/test_cache_aware_routing.py --ignore=tests/unit/proxy/common_utils/test_cache_aware_routing.py
--ignore=tests/unit/proxy/common_utils/test_check_batch_cost.py --ignore=tests/unit/proxy/common_utils/test_check_batch_cost.py
--ignore=tests/unit/proxy/common_utils/test_check_responses_cost.py --ignore=tests/unit/proxy/common_utils/test_check_responses_cost.py
--ignore=tests/unit/proxy/common_utils/test_proxy_encrypt_decrypt.py --ignore=tests/unit/proxy/common_utils/test_proxy_encrypt_decrypt.py
--ignore=tests/unit/proxy/common_utils/test_realtime_cache.py --ignore=tests/unit/proxy/common_utils/test_realtime_cache.py
python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
dist: loadscope
- shard: proxy-infra-routing
test-path: |-
tests/unit/proxy/middleware
--ignore=tests/unit/proxy/middleware/test_request_size_limit_middleware.py
tests/unit/proxy/pass_through_endpoints
tests/unit/proxy/mcp_server/test_tool_registry.py
tests/unit/proxy/enterprise_billing tests/unit/proxy/enterprise_billing
tests/unit/proxy/types_utils tests/unit/proxy/types_utils
tests/unit/proxy/logging_endpoints tests/unit/proxy/logging_endpoints
@ -654,30 +663,34 @@ jobs:
job-timeout-minutes: 60 job-timeout-minutes: 60
dist: loadscope dist: loadscope
- shard: proxy-infra-root-a-p - shard: proxy-infra-root
test-path: |- test-path: |-
tests/unit/proxy/test_[_a-p]*.py tests/unit/proxy/test_*.py
--deselect=tests/unit/proxy/test_aproxy_startup.py --ignore=tests/unit/proxy/test_aproxy_startup.py
--deselect=tests/unit/proxy/test_component_allowlists.py --ignore=tests/unit/proxy/test_credential_slot_registry.py
--deselect=tests/unit/proxy/test_credential_slot_registry.py --ignore=tests/unit/proxy/test_custom_callback_input.py
--deselect=tests/unit/proxy/test_custom_callback_input.py --ignore=tests/unit/proxy/test_custom_logger_s3_gcs.py
--deselect=tests/unit/proxy/test_custom_logger_s3_gcs.py --ignore=tests/unit/proxy/test_custom_tokenizer_bug.py
--deselect=tests/unit/proxy/test_custom_tokenizer_bug.py --ignore=tests/unit/proxy/test_db_schema_changes.py
--deselect=tests/unit/proxy/test_db_schema_changes.py --ignore=tests/unit/proxy/test_deprecated_key_grace_period.py
--deselect=tests/unit/proxy/test_deprecated_key_grace_period.py --ignore=tests/unit/proxy/test_get_favicon.py
--deselect=tests/unit/proxy/test_get_favicon.py --ignore=tests/unit/proxy/test_get_image.py
--deselect=tests/unit/proxy/test_get_image.py --ignore=tests/unit/proxy/test_prisma_client_backoff_retry.py
--deselect=tests/unit/proxy/test_prisma_client_backoff_retry.py --ignore=tests/unit/proxy/test_prompt_test_endpoint.py
--deselect=tests/unit/proxy/test_prometheus_metrics_server.py --ignore=tests/unit/proxy/test_proxy_config_unit_test.py
--deselect=tests/unit/proxy/test_prompt_test_endpoint.py --ignore=tests/unit/proxy/test_proxy_custom_auth.py
--deselect=tests/unit/proxy/test_proxy_cli.py --ignore=tests/unit/proxy/test_proxy_reject_logging.py
--deselect=tests/unit/proxy/test_proxy_config_unit_test.py --ignore=tests/unit/proxy/test_proxy_server.py
--deselect=tests/unit/proxy/test_proxy_custom_auth.py --ignore=tests/unit/proxy/test_proxy_setting_guardrails.py
--deselect=tests/unit/proxy/test_proxy_reject_logging.py --ignore=tests/unit/proxy/test_proxy_token_counter.py
--deselect=tests/unit/proxy/test_proxy_server.py --ignore=tests/unit/proxy/test_proxy_utils.py
--deselect=tests/unit/proxy/test_proxy_setting_guardrails.py --ignore=tests/unit/proxy/test_reducto_ocr_route.py
--deselect=tests/unit/proxy/test_proxy_token_counter.py --ignore=tests/unit/proxy/test_response_polling_pre_call_checks.py
--deselect=tests/unit/proxy/test_proxy_utils.py --ignore=tests/unit/proxy/test_server_root_path.py
--ignore=tests/unit/proxy/test_ui_path_detection.py
--ignore=tests/unit/proxy/test_unit_test_proxy_hooks.py
--ignore=tests/unit/proxy/test_update_spend.py
--ignore=tests/unit/proxy/test_zero_cost_model_budget_bypass.py
python-version: "3.12" python-version: "3.12"
workers: 4 workers: 4
reruns: 0 reruns: 0
@ -685,35 +698,6 @@ jobs:
job-timeout-minutes: 60 job-timeout-minutes: 60
dist: loadscope dist: loadscope
- shard: proxy-infra-root-q-z
test-path: |-
tests/unit/proxy/test_[q-z]*.py
--deselect=tests/unit/proxy/test_reducto_ocr_route.py
--deselect=tests/unit/proxy/test_response_polling_pre_call_checks.py
--deselect=tests/unit/proxy/test_server_root_path.py
--deselect=tests/unit/proxy/test_ui_path_detection.py
--deselect=tests/unit/proxy/test_unit_test_proxy_hooks.py
--deselect=tests/unit/proxy/test_update_spend.py
--deselect=tests/unit/proxy/test_zero_cost_model_budget_bypass.py
python-version: "3.12"
workers: 4
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
dist: loadscope
- shard: proxy-subprocess
test-path: |-
tests/unit/proxy/test_component_allowlists.py
tests/unit/proxy/test_prometheus_metrics_server.py
tests/unit/proxy/test_proxy_cli.py
python-version: "3.12"
workers: 2
reruns: 0
timeout-minutes: 20
job-timeout-minutes: 60
dist: loadscope
- shard: caching-local - shard: caching-local
test-path: >- test-path: >-
tests/unit/caching tests/unit/caching
@ -1060,7 +1044,7 @@ jobs:
fi fi
uv run --no-sync python -c 'import os, sys; print(sys.version); assert f"{sys.version_info.major}.{sys.version_info.minor}" == os.environ["UV_PYTHON"]' uv run --no-sync python -c 'import os, sys; print(sys.version); assert f"{sys.version_info.major}.{sys.version_info.minor}" == os.environ["UV_PYTHON"]'
- name: Check litellm/schema.prisma against the merge base - name: Check schema.prisma against the merge base
if: steps.changes.outputs.decision != 'skip' && matrix.shard == 'db-and-spend' && github.event_name == 'pull_request' if: steps.changes.outputs.decision != 'skip' && matrix.shard == 'db-and-spend' && github.event_name == 'pull_request'
timeout-minutes: 3 timeout-minutes: 3
env: env:
@ -1073,7 +1057,7 @@ jobs:
merge_base=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${base_ref}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha') merge_base=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${base_ref}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
test -n "$merge_base" test -n "$merge_base"
git fetch --no-tags --depth=1 origin "$merge_base" git fetch --no-tags --depth=1 origin "$merge_base"
uv run --no-sync python litellm/scripts/check_schema_compatibility.py --base-commit "$merge_base" uv run --no-sync python scripts/check_schema_compatibility.py --base-commit "$merge_base"
- name: Cache Prisma binaries - name: Cache Prisma binaries
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
@ -1138,7 +1122,7 @@ jobs:
--timeout="${TEST_TIMEOUT_SECONDS}" \ --timeout="${TEST_TIMEOUT_SECONDS}" \
--rerun-except "from pytest-timeout" \ --rerun-except "from pytest-timeout" \
--durations=20 \ --durations=20 \
--cov=./litellm --cov=./litellm/enterprise/litellm_enterprise \ --cov=./litellm --cov=./enterprise/litellm_enterprise \
--cov-report=xml:coverage.xml \ --cov-report=xml:coverage.xml \
--cov-config=pyproject.toml --cov-config=pyproject.toml
status=$? status=$?
@ -1168,7 +1152,7 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
defaults: defaults:
run: run:
working-directory: litellm/ui/litellm-dashboard working-directory: ui/litellm-dashboard
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -1187,9 +1171,9 @@ jobs:
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with: with:
node-version-file: litellm/ui/litellm-dashboard/.nvmrc node-version-file: ui/litellm-dashboard/.nvmrc
cache: "npm" cache: "npm"
cache-dependency-path: litellm/ui/litellm-dashboard/package-lock.json cache-dependency-path: ui/litellm-dashboard/package-lock.json
- name: Install dependencies - name: Install dependencies
if: steps.changes.outputs.decision != 'skip' if: steps.changes.outputs.decision != 'skip'
@ -1359,16 +1343,17 @@ jobs:
- name: Run unit tests - name: Run unit tests
run: | run: |
chart=litellm/deploy/helm/litellm for chart in helm/litellm-helm helm/litellm; do
declared="$(grep -h '^suite:' "$chart"/tests/*.yaml | wc -l | tr -d '[:space:]')" declared="$(grep -h '^suite:' "$chart"/tests/*.yaml | wc -l | tr -d '[:space:]')"
output="$(mktemp)" output="$(mktemp)"
helm unittest -f 'tests/*.yaml' "$chart" | tee "$output" helm unittest -f 'tests/*.yaml' "$chart" | tee "$output"
executed="$(sed -n 's/^Test Suites:.*[[:space:]]\([0-9][0-9]*\) total$/\1/p' "$output")" executed="$(sed -n 's/^Test Suites:.*[[:space:]]\([0-9][0-9]*\) total$/\1/p' "$output")"
if [ "$declared" != "$executed" ]; then if [ "$declared" != "$executed" ]; then
echo "::error::$chart declares $declared test suites but helm-unittest ran $executed. Suites are being skipped silently, so their assertions never execute." echo "::error::$chart declares $declared test suites but helm-unittest ran $executed. Suites are being skipped silently, so their assertions never execute."
exit 1 exit 1
fi fi
echo "$chart: all $declared declared test suites ran" echo "$chart: all $declared declared test suites ran"
done
openai-sdk-compat: openai-sdk-compat:
name: OpenAI ${{ matrix.openai-version }} compatibility name: OpenAI ${{ matrix.openai-version }} compatibility
needs: rust-bridge needs: rust-bridge

View file

@ -8,13 +8,13 @@ on:
- main - main
- "litellm_**" - "litellm_**"
paths: paths:
- "litellm/vscode-extension/**" - "vscode-extension/**"
- ".github/workflows/test-vscode-extension.yml" - ".github/workflows/test-vscode-extension.yml"
push: push:
branches: branches:
- main - main
paths: paths:
- "litellm/vscode-extension/**" - "vscode-extension/**"
- ".github/workflows/test-vscode-extension.yml" - ".github/workflows/test-vscode-extension.yml"
concurrency: concurrency:
@ -27,7 +27,7 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
defaults: defaults:
run: run:
working-directory: litellm/vscode-extension working-directory: vscode-extension
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -41,7 +41,7 @@ jobs:
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
cache-dependency-path: litellm/vscode-extension/package-lock.json cache-dependency-path: vscode-extension/package-lock.json
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@ -59,5 +59,5 @@ jobs:
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with: with:
name: litellm-vscode name: litellm-vscode
path: litellm/vscode-extension/*.vsix path: vscode-extension/*.vsix
if-no-files-found: error if-no-files-found: error

21
.gitignore vendored
View file

@ -18,7 +18,7 @@ __pycache__/
# Rust bridge build artifacts (compiled, platform-specific; regenerated by maturin/cargo) # Rust bridge build artifacts (compiled, platform-specific; regenerated by maturin/cargo)
litellm/rust_bridge/_native*.so litellm/rust_bridge/_native*.so
litellm/rust_bridge/_native*.pyd litellm/rust_bridge/_native*.pyd
litellm/rust/target/ litellm-rust/target/
# Python package build output # Python package build output
dist/ dist/
@ -55,14 +55,14 @@ hosted_config.yaml
litellm/proxy/tests/node_modules litellm/proxy/tests/node_modules
litellm/proxy/tests/package.json litellm/proxy/tests/package.json
litellm/proxy/tests/package-lock.json litellm/proxy/tests/package-lock.json
litellm/ui/litellm-dashboard/.next ui/litellm-dashboard/.next
litellm/ui/litellm-dashboard/node_modules ui/litellm-dashboard/node_modules
litellm/ui/litellm-dashboard/next-env.d.ts ui/litellm-dashboard/next-env.d.ts
*.tsbuildinfo *.tsbuildinfo
litellm/ui/litellm-dashboard/package.json ui/litellm-dashboard/package.json
litellm/ui/litellm-dashboard/package-lock.json ui/litellm-dashboard/package-lock.json
litellm/deploy/helm/litellm/charts/*.tgz helm/litellm-helm/*.tgz
litellm/deploy/helm/*.tgz helm/*.tgz
litellm/proxy/vertex_key.json litellm/proxy/vertex_key.json
**/.vim/ **/.vim/
**/node_modules **/node_modules
@ -101,11 +101,12 @@ tests/test_custom_dir/*
test.py test.py
litellm_config.yaml litellm_config.yaml
!.github/observatory/litellm_config.yaml
.cursor .cursor
litellm/proxy/to_delete_loadtest_work/* litellm/proxy/to_delete_loadtest_work/*
update_model_cost_map.py update_model_cost_map.py
tests/unit/proxy/mcp_server/test_mcp_server_manager.py tests/unit/proxy/mcp_server/test_mcp_server_manager.py
litellm/scripts/test_vertex_ai_search.py scripts/test_vertex_ai_search.py
LAZY_LOADING_IMPROVEMENTS.md LAZY_LOADING_IMPROVEMENTS.md
STABILIZATION_TODO.md STABILIZATION_TODO.md
**/test-results **/test-results
@ -145,7 +146,7 @@ crash.*.log
# pytest coverage data # pytest coverage data
.coverage .coverage
litellm/ui/litellm-dashboard/out/ ui/litellm-dashboard/out/
litellm.log litellm.log
.coverage-rust .coverage-rust

5
.npmrc Normal file
View file

@ -0,0 +1,5 @@
# Supply-chain hardening
# Packages needing lifecycle scripts: npm rebuild <pkg>
ignore-scripts=true
# Protects local npm install only — npm ci (used in CI) ignores this
min-release-age=3

View file

@ -5,13 +5,13 @@ Add custom rule YAML files here. Semgrep loads all `.yml`/`.yaml` files under th
**Run only custom rules (CI / fail on findings):** **Run only custom rules (CI / fail on findings):**
```bash ```bash
semgrep scan --config .github/semgrep/rules . --error semgrep scan --config .semgrep/rules . --error
``` ```
**Run with registry + custom rules:** **Run with registry + custom rules:**
```bash ```bash
semgrep scan --config auto --config .github/semgrep/rules . semgrep scan --config auto --config .semgrep/rules .
``` ```
**Layout:** **Layout:**

View file

@ -1,6 +1,7 @@
Do not write comments unless they are any of: Do not write comments unless they are any of:
- absolutely necessary to explain some very complex business logic (in which case, keep it concise and clear) - absolutely necessary to explain some very complex business logic (in which case, keep it concise and clear)
- used as an input for tools to read and act on. For example: - used as an input for tools to read and act on. For example:
- entries in `.git-blame-ignore-revs` saying which commit is excluded from git blame
- a lint or type checker suppression like `# mutable-ok` or `# pyright: ignore[reportArgumentType] # <reason>` when introducing a truly unavoidable violation - a lint or type checker suppression like `# mutable-ok` or `# pyright: ignore[reportArgumentType] # <reason>` when introducing a truly unavoidable violation
- a TODO or FIXME - a TODO or FIXME
- Not great to have those, but if it's unavoidable, make sure to include a strong, concise reason for why it's there or, better yet, link to a GitHub issue for the follow-up work - Not great to have those, but if it's unavoidable, make sure to include a strong, concise reason for why it's there or, better yet, link to a GitHub issue for the follow-up work
@ -30,7 +31,9 @@ A test must only fail when litellm code changes. Never pin facts we don't own (a
End-to-end tests belong in `tests/e2e/`. A rule about one directory's files, like its harness or which CI runs it, goes in that directory's `AGENTS.md`, never only in a README or here. Read a directory's `AGENTS.md` before editing files in it End-to-end tests belong in `tests/e2e/`. A rule about one directory's files, like its harness or which CI runs it, goes in that directory's `AGENTS.md`, never only in a README or here. Read a directory's `AGENTS.md` before editing files in it
When creating PRs, target the repository's current default branch for both internal and external / OSS contributions. Check it with `python3 litellm/scripts/default_branch.py --branch` instead of assuming a branch name or relying on cached `origin/HEAD` Before you build a new way to move data between the endpoints, the provider transformations, and cost tracking or logging, read the architecture decision records in `docs/adrs/`, which say why those layers are shaped the way they are
When creating PRs, target the repository's current default branch for both internal and external / OSS contributions. Check it with `python3 scripts/default_branch.py --branch` instead of assuming a branch name or relying on cached `origin/HEAD`
When writing a PR body, treat the comments and imperative instructions inside .github/PULL_REQUEST_TEMPLATE/general.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/PULL_REQUEST_TEMPLATE/general.md from disk before writing a PR body to make sure you see every comment rule. A section you have nothing to put in (Relevant issues, Affected release, Linear ticket, Caveats, QA runbook, and so on) is removed entirely, heading included, never left as an empty title When writing a PR body, treat the comments and imperative instructions inside .github/PULL_REQUEST_TEMPLATE/general.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/PULL_REQUEST_TEMPLATE/general.md from disk before writing a PR body to make sure you see every comment rule. A section you have nothing to put in (Relevant issues, Affected release, Linear ticket, Caveats, QA runbook, and so on) is removed entirely, heading included, never left as an empty title
@ -56,17 +59,17 @@ Don't hesitate to use values in .env to get needed API keys and other secrets, a
Python max line length is 120, not 88 Python max line length is 120, not 88
The five lint gates (`litellm/scripts/ruff_strict_gate.py`, `litellm/scripts/type_discipline_gate.py`, `litellm/scripts/test_quality_gate.py`, `litellm/scripts/openapi_docs_gate.py`, `litellm/scripts/type_check_gate.py`) compare each rule's codebase count on your branch against the count at its merge-base with the default branch, and a rule may not grow. There are no budget files to edit or ratchet: when a gate fails, fix the violations the branch introduced or remove at least as many of that rule elsewhere in the tree. The one exception is `reportAny` / `reportExplicitAny`, which share a fixed codebase-wide cap in `ANY_CAPS` in `litellm/scripts/type_check_gate.py` because Any spreads past the lines you touch. A branch may add Anys while the total stays under that cap. Only lower `ANY_CAPS`, and only in its own PR to the default branch, never raise it on a feature branch The five lint gates (`scripts/ruff_strict_gate.py`, `scripts/type_discipline_gate.py`, `scripts/test_quality_gate.py`, `scripts/openapi_docs_gate.py`, `scripts/type_check_gate.py`) compare each rule's codebase count on your branch against the count at its merge-base with the default branch, and a rule may not grow. There are no budget files to edit or ratchet: when a gate fails, fix the violations the branch introduced or remove at least as many of that rule elsewhere in the tree. The one exception is `reportAny` / `reportExplicitAny`, which share a fixed codebase-wide cap in `ANY_CAPS` in `scripts/type_check_gate.py` because Any spreads past the lines you touch. A branch may add Anys while the total stays under that cap. Only lower `ANY_CAPS`, and only in its own PR to the default branch, never raise it on a feature branch
`make check` (f.k.a. `make pre-commit`, which still works identically as an alias) saves its complete output to a log file in .git (overwriting previous logs) and prints that path as its first and last output lines. To inspect a run, read or grep that log instead of re-running the multi-minute checks just to see a different slice `make check` (f.k.a. `make pre-commit`, which still works identically as an alias) saves its complete output to a log file in .git (overwriting previous logs) and prints that path as its first and last output lines. To inspect a run, read or grep that log instead of re-running the multi-minute checks just to see a different slice
`make check`, `make lint`, `litellm/scripts/pre_commit_lint.sh`, and the standalone lint gates (`litellm/scripts/ruff_strict_gate.py`, `litellm/scripts/type_discipline_gate.py`, `litellm/scripts/type_check_gate.py`) each hold one of 2 machine-wide slots, so when other sessions or worktrees on the same box are already running heavy work, yours prints "all N machine-wide slots are busy" followed by "queueing" and then stays quiet until a slot frees. Give the command a long timeout and let it wait rather than killing it, retrying it, or assuming it hung. Don't change the # of machine-wide slots or make it unlimited by setting `LITELLM_GATE_SLOTS=0` `make check`, `make lint`, `scripts/pre_commit_lint.sh`, and the standalone lint gates (`scripts/ruff_strict_gate.py`, `scripts/type_discipline_gate.py`, `scripts/type_check_gate.py`) each hold one of 2 machine-wide slots, so when other sessions or worktrees on the same box are already running heavy work, yours prints "all N machine-wide slots are busy" followed by "queueing" and then stays quiet until a slot frees. Give the command a long timeout and let it wait rather than killing it, retrying it, or assuming it hung. Don't change the # of machine-wide slots or make it unlimited by setting `LITELLM_GATE_SLOTS=0`
If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and pushing `reportAny` / `reportExplicitAny` closer to their cap in `ANY_CAPS`, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and pushing `reportAny` / `reportExplicitAny` closer to their cap in `ANY_CAPS`, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in
If you get an LIT001 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `MappingProxyType()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally, `# mutable-ok` is never used. Reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason If you get an LIT001 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `MappingProxyType()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally, `# mutable-ok` is never used. Reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason
Every lint or type suppression must name the exact rule inside brackets and carry a reason comment, e.g. `# pyright: ignore[reportArgumentType] # stubs lack async overload` or `# noqa: TID251 # <reason>`. `# type: ignore` is banned (LIT009): `[tool.pyright]` in pyproject.toml sets `enableTypeIgnoreComments` to false, so it silently does nothing Every lint or type suppression must name the exact rule inside brackets and carry a reason comment, e.g. `# pyright: ignore[reportArgumentType] # stubs lack async overload` or `# noqa: TID251 # <reason>`. `# type: ignore` is banned (LIT009): pyrightconfig.json sets `enableTypeIgnoreComments` to false, so it silently does nothing
Commit and push your work when you're done without asking Commit and push your work when you're done without asking
@ -88,6 +91,8 @@ Do not put names of customers or customer company names in code, PR descriptions
CI supply-chain safety: Never pipe a remote script into a shell (`curl ... | bash`, `wget ... | sh`). Download the artifact to a file, verify its SHA-256 checksum, then install. Pin every external tool to a specific version with a full URL (not `latest` or `stable`). Verify checksums for all downloaded binaries, using the provider's official `.sha256` / `.sha256sum` sidecar when available. These rules apply to every download in CI CI supply-chain safety: Never pipe a remote script into a shell (`curl ... | bash`, `wget ... | sh`). Download the artifact to a file, verify its SHA-256 checksum, then install. Pin every external tool to a specific version with a full URL (not `latest` or `stable`). Verify checksums for all downloaded binaries, using the provider's official `.sha256` / `.sha256sum` sidecar when available. These rules apply to every download in CI
For database operations, connection ownership and proxy dependency wiring, follow [database-access](.agents/skills/database-access/SKILL.md)
Prisma migrations apply synchronously at proxy boot, before it serves traffic, so a migration must only change schema, never rewrite rows. No `UPDATE`, `DELETE` or `MERGE`, and no `INSERT ... SELECT`: on a spend-log-sized table any of those is minutes of downtime plus a doubled heap that plain autovacuum won't give back. `tests/code_coverage_tests/check_migrations_no_data_rewrites.py` enforces this. When a rewrite is genuinely bounded and has to ship inside the migration, mark the statement `-- data-migration-ok: <what bounds it>` Prisma migrations apply synchronously at proxy boot, before it serves traffic, so a migration must only change schema, never rewrite rows. No `UPDATE`, `DELETE` or `MERGE`, and no `INSERT ... SELECT`: on a spend-log-sized table any of those is minutes of downtime plus a doubled heap that plain autovacuum won't give back. `tests/code_coverage_tests/check_migrations_no_data_rewrites.py` enforces this. When a rewrite is genuinely bounded and has to ship inside the migration, mark the statement `-- data-migration-ok: <what bounds it>`
Follow these coding conventions for new/updated code (a three-line fix in a legacy file shouldn't trigger huge drive-by refactors): Follow these coding conventions for new/updated code (a three-line fix in a legacy file shouldn't trigger huge drive-by refactors):

419
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,419 @@
# LiteLLM Architecture - LiteLLM SDK + AI Gateway
This document helps contributors understand where to make changes in LiteLLM.
For why a layer is shaped the way it is, read the architecture decision records in [`docs/adrs/`](docs/adrs/AGENTS.md) (Python SDK and proxy) and [`litellm-rust/docs/adrs/`](litellm-rust/docs/adrs/AGENTS.md) (Rust core)
---
## How It Works
The LiteLLM AI Gateway (Proxy) uses the LiteLLM SDK internally for all LLM calls:
```
OpenAI SDK (client) ──▶ LiteLLM AI Gateway (proxy/) ──▶ LiteLLM SDK (litellm/) ──▶ LLM API
Anthropic SDK (client) ──▶ LiteLLMAI Gateway (proxy/) ──▶ LiteLLM SDK (litellm/) ──▶ LLM API
Any HTTP client ──▶ LiteLLMAI Gateway (proxy/) ──▶ LiteLLM SDK (litellm/) ──▶ LLM API
```
The **AI Gateway** adds authentication, rate limiting, budgets, and routing on top of the SDK.
The **SDK** handles the actual LLM provider calls, request/response transformations, and streaming.
---
## 1. AI Gateway (Proxy) Request Flow
The AI Gateway (`litellm/proxy/`) wraps the SDK with authentication, rate limiting, and management features.
```mermaid
sequenceDiagram
participant Client
participant ProxyServer as proxy/proxy_server.py
participant Auth as proxy/auth/user_api_key_auth.py
participant Redis as Redis Cache
participant Hooks as proxy/hooks/
participant Router as router.py
participant Main as main.py + utils.py
participant Handler as llms/custom_httpx/llm_http_handler.py
participant Transform as llms/{provider}/chat/transformation.py
participant Provider as LLM Provider API
participant CostCalc as cost_calculator.py
participant LoggingObj as litellm_logging.py
participant DBWriter as db/db_spend_update_writer.py
participant Postgres as PostgreSQL
%% Request Flow
Client->>ProxyServer: POST /v1/chat/completions
ProxyServer->>Auth: user_api_key_auth()
Auth->>Redis: Check API key cache
Redis-->>Auth: Key info + spend limits
ProxyServer->>Hooks: parallel_request_limiter, cache_control_check
Hooks->>Redis: Check/increment rate limit counters
ProxyServer->>Router: route_request()
Router->>Main: litellm.acompletion()
Main->>Handler: BaseLLMHTTPHandler.completion()
Handler->>Transform: ProviderConfig.transform_request()
Handler->>Provider: HTTP Request
Provider-->>Handler: Response
Handler->>Transform: ProviderConfig.transform_response()
Transform-->>Handler: ModelResponse
Handler-->>Main: ModelResponse
%% Cost Attribution (in utils.py wrapper)
Main->>LoggingObj: update_response_metadata()
LoggingObj->>CostCalc: _response_cost_calculator()
CostCalc->>CostCalc: completion_cost(tokens × price)
CostCalc-->>LoggingObj: response_cost
LoggingObj-->>Main: Set response._hidden_params["response_cost"]
Main-->>ProxyServer: ModelResponse (with cost in _hidden_params)
%% Response Headers + Async Logging
ProxyServer->>ProxyServer: Extract cost from hidden_params
ProxyServer->>LoggingObj: async_success_handler()
LoggingObj->>Hooks: async_log_success_event()
Hooks->>DBWriter: update_database(response_cost)
DBWriter->>Redis: Queue spend increment
DBWriter->>Postgres: Batch write spend logs (async)
ProxyServer-->>Client: ModelResponse + x-litellm-response-cost header
```
### Proxy Components
```mermaid
graph TD
subgraph "Incoming Request"
Client["POST /v1/chat/completions"]
end
subgraph "proxy/proxy_server.py"
Endpoint["chat_completion()"]
end
subgraph "proxy/auth/"
Auth["user_api_key_auth()"]
end
subgraph "proxy/"
PreCall["litellm_pre_call_utils.py"]
RouteRequest["route_llm_request.py"]
end
subgraph "litellm/"
Router["router.py"]
Main["main.py"]
end
subgraph "Infrastructure"
DualCache["DualCache<br/>(in-memory + Redis)"]
Postgres["PostgreSQL<br/>(keys, teams, spend logs)"]
end
Client --> Endpoint
Endpoint --> Auth
Auth --> DualCache
DualCache -.->|cache miss| Postgres
Auth --> PreCall
PreCall --> RouteRequest
RouteRequest --> Router
Router --> DualCache
Router --> Main
Main --> Client
```
**Key proxy files:**
- `proxy/proxy_server.py` - Main API endpoints
- `proxy/auth/` - Authentication (API keys, JWT, OAuth2)
- `proxy/hooks/` - Proxy-level callbacks
- `router.py` - Load balancing, fallbacks
- `router_strategy/` - Routing algorithms (`lowest_latency.py`, `simple_shuffle.py`, etc.)
**LLM-specific proxy endpoints:**
| Endpoint | Directory | Purpose |
|----------|-----------|---------|
| `/v1/messages` | `proxy/anthropic_endpoints/` | Anthropic Messages API |
| `/vertex-ai/*` | `proxy/vertex_ai_endpoints/` | Vertex AI passthrough |
| `/gemini/*` | `proxy/google_endpoints/` | Google AI Studio passthrough |
| `/v1/images/*` | `proxy/image_endpoints/` | Image generation |
| `/v1/batches` | `proxy/batches_endpoints/` | Batch processing |
| `/v1/files` | `proxy/openai_files_endpoints/` | File uploads |
| `/v1/fine_tuning` | `proxy/fine_tuning_endpoints/` | Fine-tuning jobs |
| `/v1/rerank` | `proxy/rerank_endpoints/` | Reranking |
| `/v1/responses` | `proxy/response_api_endpoints/` | OpenAI Responses API |
| `/v1/vector_stores` | `proxy/vector_store_endpoints/` | Vector stores |
| `/*` (passthrough) | `proxy/pass_through_endpoints/` | Direct provider passthrough |
**Proxy Hooks** (`proxy/hooks/__init__.py`):
| Hook | File | Purpose |
|------|------|---------|
| `parallel_request_limiter` | `proxy/hooks/parallel_request_limiter_v3.py` | Rate limiting per key/user |
| `cache_control_check` | `proxy/hooks/cache_control_check.py` | Cache validation |
| `responses_id_security` | `proxy/hooks/responses_id_security.py` | Response ID validation |
| `litellm_skills` | `proxy/hooks/litellm_skills/main.py` | Skills injection |
To add a new proxy hook, implement `CustomLogger` and register in `PROXY_HOOKS`.
### Infrastructure Components
The AI Gateway uses external infrastructure for persistence and caching:
```mermaid
graph LR
subgraph "AI Gateway (proxy/)"
Proxy["proxy_server.py"]
Auth["auth/user_api_key_auth.py"]
DBWriter["db/db_spend_update_writer.py<br/>DBSpendUpdateWriter"]
InternalCache["utils.py<br/>InternalUsageCache"]
CostCallback["hooks/proxy_track_cost_callback.py<br/>_ProxyDBLogger"]
Scheduler["APScheduler<br/>ProxyStartupEvent"]
end
subgraph "SDK (litellm/)"
Router["router.py<br/>Router.cache (DualCache)"]
LLMCache["caching/caching_handler.py<br/>LLMCachingHandler"]
CacheClass["caching/caching.py<br/>Cache"]
end
subgraph "Redis (caching/redis_cache.py)"
RateLimit["Rate Limit Counters"]
SpendQueue["Spend Increment Queue"]
KeyCache["API Key Cache"]
TPM_RPM["TPM/RPM Tracking"]
Cooldowns["Deployment Cooldowns"]
LLMResponseCache["LLM Response Cache"]
end
subgraph "PostgreSQL (proxy/schema.prisma)"
Keys["LiteLLM_VerificationToken"]
Teams["LiteLLM_TeamTable"]
SpendLogs["LiteLLM_SpendLogs"]
Users["LiteLLM_UserTable"]
end
Auth --> InternalCache
InternalCache --> KeyCache
InternalCache -.->|cache miss| Keys
InternalCache --> RateLimit
Router --> TPM_RPM
Router --> Cooldowns
LLMCache --> CacheClass
CacheClass --> LLMResponseCache
CostCallback --> DBWriter
DBWriter --> SpendQueue
DBWriter --> SpendLogs
Scheduler --> SpendLogs
Scheduler --> Keys
```
| Component | Purpose | Key Files/Classes |
|-----------|---------|-------------------|
| **Redis** | Rate limiting, API key caching, TPM/RPM tracking, cooldowns, LLM response caching, spend queuing | `caching/redis_cache.py` (`RedisCache`), `caching/dual_cache.py` (`DualCache`) |
| **PostgreSQL** | API keys, teams, users, spend logs | `proxy/utils.py` (`PrismaClient`), `proxy/schema.prisma` |
| **InternalUsageCache** | Proxy-level cache for rate limits + API keys (in-memory + Redis) | `proxy/utils.py` (`InternalUsageCache`) |
| **Router.cache** | TPM/RPM tracking, deployment cooldowns, client caching (in-memory + Redis) | `router.py` (`Router.cache: DualCache`) |
| **LLMCachingHandler** | SDK-level LLM response/embedding caching | `caching/caching_handler.py` (`LLMCachingHandler`), `caching/caching.py` (`Cache`) |
| **DBSpendUpdateWriter** | Batches spend updates to reduce DB writes | `proxy/db/db_spend_update_writer.py` (`DBSpendUpdateWriter`) |
| **Cost Tracking** | Calculates and logs response costs | `proxy/hooks/proxy_track_cost_callback.py` (`_ProxyDBLogger`) |
**Background Jobs** (APScheduler, initialized in `proxy/proxy_server.py` → `ProxyStartupEvent.initialize_scheduled_background_jobs()`):
| Job | Interval | Purpose | Key Files |
|-----|----------|---------|-----------|
| `update_spend` | 60s | Batch write spend logs to PostgreSQL | `proxy/db/db_spend_update_writer.py` |
| `reset_budget` | 10-12min | Reset budgets for keys/users/teams | `proxy/common_utils/reset_budget_job.py` |
| `add_deployment` | 10s | Sync new model deployments from DB | `proxy/proxy_server.py` (`ProxyConfig`) |
| `cleanup_old_spend_logs` | cron/interval | Delete old spend logs | `proxy/db/db_transaction_queue/spend_log_cleanup.py` |
| `check_batch_cost` | 30min | Calculate costs for batch jobs | `enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py` |
| `check_responses_cost` | 30min | Calculate costs for responses API | `enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py` |
| `process_rotations` | 1hr | Auto-rotate API keys | `proxy/common_utils/key_rotation_manager.py` |
| `_run_background_health_check` | continuous | Health check model deployments | `proxy/proxy_server.py` |
| `send_weekly_spend_report` | weekly | Slack spend alerts | `proxy/utils.py` (`SlackAlerting`) |
| `send_monthly_spend_report` | monthly | Slack spend alerts | `proxy/utils.py` (`SlackAlerting`) |
**Cost Attribution Flow:**
1. LLM response returns to `utils.py` wrapper after `litellm.acompletion()` completes
2. `update_response_metadata()` (`litellm_core_utils/llm_response_utils/response_metadata.py`) is called
3. `logging_obj._response_cost_calculator()` (`litellm_core_utils/litellm_logging.py`) calculates cost via `litellm.completion_cost()` (`cost_calculator.py`)
4. Cost is stored in `response._hidden_params["response_cost"]`
5. `proxy/common_request_processing.py` extracts cost from `hidden_params` and adds to response headers (`x-litellm-response-cost`)
6. `logging_obj.async_success_handler()` triggers callbacks including `_ProxyDBLogger.async_log_success_event()`
7. `DBSpendUpdateWriter.update_database()` queues spend increments to Redis
8. Background job `update_spend` flushes queued spend to PostgreSQL every 60s
Cost lookup falls back to the response model when the provider-reported name cannot be priced. Explicitly registered zero rates remain zero, and custom deployment pricing or an explicit `base_model` retain precedence
### Data Access Layer (Models & Repositories)
Database entities and the operations on them live in two packages at the root of `litellm/` so both the gateway (`proxy/`) and the SDK can use them without importing proxy internals:
- `litellm/models/` holds the canonical Pydantic definitions for every persisted entity (`LiteLLM_VerificationToken`, `LiteLLM_TeamTable`, `LiteLLM_UserTable`, etc.). `proxy/_types.py` re-exports these for backwards compatibility, so existing imports keep working.
- `litellm/repositories/` holds the data-access layer. `BaseRepository[T]` provides the generic CRUD (`find_by_id`, `find_many`, `create`, `update`, `delete`, `count`, `exists`); entity repositories such as `VerificationTokenRepository`, `TeamRepository`, and `UserRepository` add domain-specific queries and writes on top of it.
Conventions to follow when touching this layer:
| Concern | How it's handled |
|---------|------------------|
| JSON columns | Prisma `Json` columns are stored as JSON strings. Repositories `json.dumps()` on write and `json.loads()` on read (see `_to_model` and the `_build_*_data` helpers). |
| Archive-then-delete | `delete_team` / `delete_token` copy the row into the `LiteLLM_Deleted*` table and delete the original inside a single `prisma_client.db.tx()` transaction. Archive payloads are built explicitly so only columns that exist on the archive table are written. |
| Column vs. field names | Where a model field differs from its DB column (for example `org_id` maps to the `organization_id` column), the repository translates in both directions rather than relying on Pydantic to guess. |
| Array mutations | Adds use Prisma's atomic `push` (`add_member`, `add_admin`, `add_models`) to avoid read-modify-write races. Removals fall back to read-modify-write because Prisma has no atomic array remove. |
To add a new entity, define the model under `litellm/models/`, re-export it from `proxy/_types.py` if existing code imports it from there, and add a repository under `litellm/repositories/` (subclass `BaseRepository` for plain CRUD, or add bespoke methods when the entity needs encryption, archiving, or atomic array updates). Mirror the tests in `tests/unit/repositories/`.
---
## 2. SDK Request Flow
The SDK (`litellm/`) provides the core LLM calling functionality used by both direct SDK users and the AI Gateway.
```mermaid
graph TD
subgraph "SDK Entry Points"
Completion["litellm.completion()"]
Messages["litellm.messages()"]
end
subgraph "main.py"
Main["completion()<br/>acompletion()"]
end
subgraph "utils.py"
GetProvider["get_llm_provider()"]
end
subgraph "llms/custom_httpx/"
Handler["llm_http_handler.py<br/>BaseLLMHTTPHandler"]
HTTP["http_handler.py<br/>HTTPHandler / AsyncHTTPHandler"]
end
subgraph "llms/{provider}/chat/"
TransformReq["transform_request()"]
TransformResp["transform_response()"]
end
subgraph "litellm_core_utils/"
Streaming["streaming_handler.py"]
end
subgraph "integrations/ (async, off main thread)"
Callbacks["custom_logger.py<br/>Langfuse, Datadog, etc."]
end
Completion --> Main
Messages --> Main
Main --> GetProvider
GetProvider --> Handler
Handler --> TransformReq
TransformReq --> HTTP
HTTP --> Provider["LLM Provider API"]
Provider --> HTTP
HTTP --> TransformResp
TransformResp --> Streaming
Streaming --> Response["ModelResponse"]
Response -.->|async| Callbacks
```
**Key SDK files:**
- `main.py` - Entry points: `completion()`, `acompletion()`, `embedding()`
- `utils.py` - `get_llm_provider()` resolves model → provider
- `llms/custom_httpx/llm_http_handler.py` - Central HTTP orchestrator
- `llms/custom_httpx/http_handler.py` - Low-level HTTP client
- `llms/{provider}/chat/transformation.py` - Provider-specific transformations
- `litellm_core_utils/streaming_handler.py` - Streaming response handling
- `integrations/` - Async callbacks (Langfuse, Datadog, etc.)
---
## 3. Translation Layer
When a request comes in, it goes through a **translation layer** that converts between API formats.
Each translation is isolated in its own file, making it easy to test and modify independently.
### Where to find translations
| Incoming API | Provider | Translation File |
|--------------|----------|------------------|
| `/v1/chat/completions` | Anthropic | `llms/anthropic/chat/transformation.py` |
| `/v1/chat/completions` | Bedrock Converse | `llms/bedrock/chat/converse_transformation.py` |
| `/v1/chat/completions` | Bedrock Invoke | `llms/bedrock/chat/invoke_transformations/anthropic_claude_transformation.py` |
| `/v1/chat/completions` | Gemini | `llms/gemini/chat/transformation.py` |
| `/v1/chat/completions` | Vertex AI | `llms/vertex_ai/gemini/transformation.py` |
| `/v1/chat/completions` | OpenAI | `llms/openai/chat/gpt_transformation.py` |
| `/v1/messages` (passthrough) | Anthropic | `llms/anthropic/messages/native/transformation.py` |
| `/v1/messages` (passthrough) | Bedrock | `llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py` |
| `/v1/messages` (passthrough) | Vertex AI | `llms/vertex_ai/vertex_ai_partner_models/anthropic/messages/transformation.py` |
| Passthrough endpoints | All | `proxy/pass_through_endpoints/llm_provider_handlers/` |
### Example: Debugging prompt caching
If `/v1/messages` → Bedrock Converse prompt caching isn't working but Bedrock Invoke works:
1. **Bedrock Converse translation**: `llms/bedrock/chat/converse_transformation.py`
2. **Bedrock Invoke translation**: `llms/bedrock/chat/invoke_transformations/anthropic_claude_transformation.py`
3. Compare how each handles `cache_control` in `transform_request()`
### How translations work
Each provider has a `Config` class that inherits from `BaseConfig` (`llms/base_llm/chat/transformation.py`):
```python
class ProviderConfig(BaseConfig):
def transform_request(self, model, messages, optional_params, litellm_params, headers):
# Convert OpenAI format → Provider format
return {"messages": transformed_messages, ...}
def transform_response(self, model, raw_response, model_response, logging_obj, ...):
# Convert Provider format → OpenAI format
return ModelResponse(choices=[...], usage=Usage(...))
```
The `BaseLLMHTTPHandler` (`llms/custom_httpx/llm_http_handler.py`) calls these methods - you never need to modify the handler itself.
---
## 4. Adding/Modifying Providers
### To add a new provider:
1. Create `llms/{provider}/chat/transformation.py`
2. Implement `Config` class with `transform_request()` and `transform_response()`
3. Add tests in `tests/llm_translation/test_{provider}.py`
### To add a feature (e.g., prompt caching):
1. Find the translation file from the table above
2. Modify `transform_request()` to handle the new parameter
3. Add unit tests that verify the transformation
### Testing checklist
When adding a feature, verify it works across all paths:
| Test | File Pattern |
|------|--------------|
| OpenAI passthrough | `tests/llm_translation/test_openai*.py` |
| Anthropic direct | `tests/llm_translation/test_anthropic*.py` |
| Bedrock Invoke | `tests/llm_translation/test_bedrock*.py` |
| Bedrock Converse | `tests/llm_translation/test_bedrock*converse*.py` |
| Vertex AI | `tests/llm_translation/test_vertex*.py` |
| Gemini | `tests/llm_translation/test_gemini*.py` |
### Unit testing translations
Translations are designed to be unit testable without making API calls:
```python
from litellm.llms.bedrock.chat.converse_transformation import BedrockConverseConfig
def test_prompt_caching_transform():
config = BedrockConverseConfig()
result = config.transform_request(
model="anthropic.claude-3-opus",
messages=[{"role": "user", "content": "test", "cache_control": {"type": "ephemeral"}}],
optional_params={},
litellm_params={},
headers={}
)
assert "cachePoint" in str(result) # Verify cache_control was translated
```

View file

@ -276,14 +276,16 @@ When the provider reports its own usage or spend numbers, compare the proxy's nu
Provider work (a new provider, a new parameter, a new usage or cost field) is the most common kind of contribution, and it is where PRs get reworked most often. Follow these rules Provider work (a new provider, a new parameter, a new usage or cost field) is the most common kind of contribution, and it is where PRs get reworked most often. Follow these rules
**Preserve zero prices.** In [`model_prices_and_context_window.json`](../model_prices_and_context_window.json), an explicit `0.0` means that the priced operation is free. A missing field or `null` means no rate is specified, so the relevant calculator may use a fallback. For example, when no batch input rate is configured, batch cache writes use `cache_creation_input_token_cost` if set, otherwise the regular input rate, then apply the batch discount. A zero ordinary input rate does not make separately priced cache reads or writes free. Test `rate is None` before substituting a fallback, since `row.get("cache_creation_input_token_cost") or fallback` would discard a valid zero. Speech keeps character pricing ahead of token pricing even when the character rate is zero. If neither rate is configured, speech cost calculation raises an error. Its model lookup preserves unset rates instead of treating compatibility defaults as explicit zero prices. Perplexity keeps a configured legacy search rate ahead of the current search rate, including zero. When registering custom prices, `None` leaves an existing rate unchanged and `0.0` overrides it **Preserve zero prices.** In [`model_prices_and_context_window.json`](model_prices_and_context_window.json), an explicit `0.0` means that the priced operation is free. A missing field or `null` means no rate is specified, so the relevant calculator may use a fallback. For example, when no batch input rate is configured, batch cache writes use `cache_creation_input_token_cost` if set, otherwise the regular input rate, then apply the batch discount. A zero ordinary input rate does not make separately priced cache reads or writes free. Test `rate is None` before substituting a fallback, since `row.get("cache_creation_input_token_cost") or fallback` would discard a valid zero. Speech keeps character pricing ahead of token pricing even when the character rate is zero. If neither rate is configured, speech cost calculation raises an error. Its model lookup preserves unset rates instead of treating compatibility defaults as explicit zero prices. Perplexity keeps a configured legacy search rate ahead of the current search rate, including zero. When registering custom prices, `None` leaves an existing rate unchanged and `0.0` overrides it
**Find the existing machinery before you add plumbing.** LiteLLM already has generic paths that move data between the endpoints, the provider transformations, and cost tracking. A second, hand-rolled path for the same data is a common reason a provider PR gets rewritten before merge. If you cannot find a mechanism for what you need, say so in the PR or ask in [#pr-review on Slack](https://join.slack.com/t/litellmossslack/shared_invite/zt-3o7nkuyfr-p_kbNJj8taRfXGgQI1~YyA) before you build one **Find the existing machinery before you add plumbing.** LiteLLM already has generic paths that move data between the endpoints, the provider transformations, and cost tracking. A second, hand-rolled path for the same data is a common reason a provider PR gets rewritten before merge. [`ARCHITECTURE.md`](ARCHITECTURE.md) shows where the layers live, and the decision records in [`docs/adrs/`](docs/adrs/AGENTS.md) explain why they are shaped that way. For example, [the ADR on provider usage fields](docs/adrs/adr_001_provider_usage_fields_ride_as_usage_extras.md) covers how provider-specific usage fields reach cost tracking. If you cannot find a mechanism for what you need, say so in the PR or ask in [#pr-review on Slack](https://join.slack.com/t/litellmossslack/shared_invite/zt-3o7nkuyfr-p_kbNJj8taRfXGgQI1~YyA) before you build one
**Bill a new provider usage field from the usage object.** Leave the field on the usage object as an extra and read it in that provider's `cost_calculator.py`. A provider without one gets a new `cost_calculator.py` and an arm for it in `cost_per_token` (`litellm/cost_calculator.py`), which picks the calculator by provider. If the provider's transform builds `Usage` from named fields, as the Anthropic, Gemini, and Bedrock Converse ones do, pass the new field there too. Streamed requests rebuild usage in `ChunkProcessor.calculate_usage` (`litellm/litellm_core_utils/streaming_chunk_builder_utils.py`), which does not copy extras yet the way the Responses-to-chat bridge does (minus a short exclusion list), so until it does, bill the field on streams by naming it there, as Anthropic's `inference_geo` is. A provider that reports its own charge for the request, as OpenRouter does in `usage.cost`, is billed at that charge from `_hidden_params` before any calculator runs, unless the deployment sets its own prices. When the field counts a built-in tool such as web search, add the provider to `get_cost_for_web_search_request` in `litellm/llms/__init__.py`, and add a gate to `litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py` only when no existing gate fires. Then show the field's cost in your proof on every endpoint it reaches, streaming included **Bill a new provider usage field from the usage object.** Leave the field on the usage object as an extra and read it in that provider's `cost_calculator.py`. A provider without one gets a new `cost_calculator.py` and an arm for it in `cost_per_token` (`litellm/cost_calculator.py`), which picks the calculator by provider. If the provider's transform builds `Usage` from named fields, as the Anthropic, Gemini, and Bedrock Converse ones do, pass the new field there too. Streamed requests rebuild usage in `ChunkProcessor.calculate_usage` (`litellm/litellm_core_utils/streaming_chunk_builder_utils.py`), which does not copy extras yet the way the Responses-to-chat bridge does (minus a short exclusion list), so until it does, bill the field on streams by naming it there, as Anthropic's `inference_geo` is. A provider that reports its own charge for the request, as OpenRouter does in `usage.cost`, is billed at that charge from `_hidden_params` before any calculator runs, unless the deployment sets its own prices. When the field counts a built-in tool such as web search, add the provider to `get_cost_for_web_search_request` in `litellm/llms/__init__.py`, and add a gate to `litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py` only when no existing gate fires. Then show the field's cost in your proof on every endpoint it reaches, streaming included
**Do not change what a caller sees to make internals easier.** Each endpoint keeps the schema of the API it emulates. A `/v1/responses` caller gets `input_tokens` and a `/v1/chat/completions` caller gets `prompt_tokens`, whatever the provider sent and whatever cost tracking needs. A provider usage field that shows up as an extra key is fine, since it only adds a key, but renaming, removing, or reshaping a key the endpoint defines is not. Reshaping a public response to feed an internal consumer breaks every user of that endpoint **Do not change what a caller sees to make internals easier.** Each endpoint keeps the schema of the API it emulates. A `/v1/responses` caller gets `input_tokens` and a `/v1/chat/completions` caller gets `prompt_tokens`, whatever the provider sent and whatever cost tracking needs. A provider usage field that shows up as an extra key is fine, since it only adds a key, but renaming, removing, or reshaping a key the endpoint defines is not. Reshaping a public response to feed an internal consumer breaks every user of that endpoint
If your change makes an architecture decision, such as a new cross-cutting mechanism or a deliberate difference from a provider's API shape, add an ADR to [`docs/adrs/`](docs/adrs/AGENTS.md) in the same PR
## Building and Running Locally ## Building and Running Locally
### LiteLLM Proxy Server ### LiteLLM Proxy Server
@ -303,8 +305,8 @@ uv run litellm --config your_config.yaml
If you want to build the Docker image yourself: If you want to build the Docker image yourself:
```bash ```bash
# Build the image (runs as a non-root user by default) # Build using the non-root Dockerfile
docker build -f litellm/deploy/docker/Dockerfile -t litellm_dev . docker build -f docker/Dockerfile.non_root -t litellm_dev .
# Generate a master key. Requests send it as the bearer token # Generate a master key. Requests send it as the bearer token
export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 32)" export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 32)"
@ -328,7 +330,7 @@ git clone https://github.com/YOUR_USERNAME/litellm.git
cd litellm cd litellm
# Navigate to the UI dashboard directory # Navigate to the UI dashboard directory
cd litellm/ui/litellm-dashboard cd ui/litellm-dashboard
# Install dependencies # Install dependencies
npm install npm install
@ -360,7 +362,7 @@ Local lint and budget checks follow origin's current default branch. They refres
## Submitting Your PR ## Submitting Your PR
1. **Push your branch**: `git push origin your-feature-branch` 1. **Push your branch**: `git push origin your-feature-branch`
2. **Create a PR**: Go to GitHub and open a pull request against the repository's current default branch. Run `python3 litellm/scripts/default_branch.py --branch` to check its name 2. **Create a PR**: Go to GitHub and open a pull request against the repository's current default branch. Run `python3 scripts/default_branch.py --branch` to check its name
3. **Fill out the PR template**: Provide clear description of changes 3. **Fill out the PR template**: Provide clear description of changes
4. **Wait for review**: Maintainers will review and provide feedback 4. **Wait for review**: Maintainers will review and provide feedback
5. **Address feedback**: Make requested changes and push updates 5. **Address feedback**: Make requested changes and push updates

View file

@ -1,10 +1,10 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# Base image for building # Base image for building
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:ae9575b121f5776e2f04e192064bc2c5665af9d1c425d620dc773aca31698712 ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7
# Runtime image # Runtime image
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:ae9575b121f5776e2f04e192064bc2c5665af9d1c425d620dc773aca31698712 ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a 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. # Pinned by digest like the other base images; bump explicitly on Node upgrades.
ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
@ -38,11 +38,11 @@ ENV NEXT_TELEMETRY_DISABLED=1 \
WORKDIR /ui WORKDIR /ui
COPY litellm/ui/litellm-dashboard/package.json litellm/ui/litellm-dashboard/package-lock.json ./ COPY ui/litellm-dashboard/package.json ui/litellm-dashboard/package-lock.json ./
COPY litellm/ui/litellm-dashboard/vendor/ ./vendor/ COPY ui/litellm-dashboard/vendor/ ./vendor/
RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline
COPY litellm/ui/litellm-dashboard/ ./ COPY ui/litellm-dashboard/ ./
RUN npm run build RUN npm run build
# Builder stage # Builder stage
@ -68,18 +68,15 @@ RUN apk add --no-cache \
libsndfile libsndfile
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \ ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
CARGO_NET_RETRY=5 \
CARGO_HTTP_MULTIPLEXING=false \
SQLX_OFFLINE=true \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0 \ UV_PYTHON_DOWNLOADS=0 \
PATH="/app/.venv/bin:${PATH}" PATH="/app/.venv/bin:${PATH}"
# Copy dependency metadata first for layer caching # Copy dependency metadata first for layer caching
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
COPY litellm/enterprise/pyproject.toml litellm/enterprise/ COPY enterprise/pyproject.toml enterprise/
COPY litellm/proxy-extras/pyproject.toml litellm/proxy-extras/ COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/
COPY litellm/packaging/litellm-proxy/pyproject.toml litellm/packaging/litellm-proxy/ COPY packaging/litellm-proxy/pyproject.toml packaging/litellm-proxy/
# Install third-party dependencies (cached unless pyproject.toml/uv.lock change) # Install third-party dependencies (cached unless pyproject.toml/uv.lock change)
RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
@ -102,10 +99,9 @@ RUN rm -rf litellm/proxy/_experimental/out
COPY --from=ui-builder /ui/out/. litellm/proxy/_experimental/out/ COPY --from=ui-builder /ui/out/. litellm/proxy/_experimental/out/
# Build Admin UI before final sync (applies the enterprise color override when present) # Build Admin UI before final sync (applies the enterprise color override when present)
RUN sed -i 's/\r$//' litellm/deploy/docker/build_admin_ui.sh && chmod +x litellm/deploy/docker/build_admin_ui.sh && ./litellm/deploy/docker/build_admin_ui.sh RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui.sh && ./docker/build_admin_ui.sh
# Install project and workspace packages (fast - deps already cached) # Install project and workspace packages (fast - deps already cached)
ARG MATURIN_PEP517_ARGS=""
RUN uv build --package litellm-proxy --wheel --out-dir /wheels RUN uv build --package litellm-proxy --wheel --out-dir /wheels
RUN uv sync --frozen --no-install-package litellm-proxy --no-default-groups --no-editable \ RUN uv sync --frozen --no-install-package litellm-proxy --no-default-groups --no-editable \
--extra proxy \ --extra proxy \
@ -119,18 +115,12 @@ RUN uv sync --frozen --no-install-package litellm-proxy --no-default-groups --no
RUN uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm_proxy-*.whl RUN uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm_proxy-*.whl
RUN cp -r litellm/enterprise litellm/runtime "$(.venv/bin/python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/litellm/"
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
npm_config_cache=/root/.npm \ npm_config_cache=/root/.npm \
prisma generate --schema=litellm/schema.prisma prisma generate --schema=./schema.prisma
RUN sed -i 's/\r$//' litellm/deploy/docker/docker-entrypoint.sh && chmod +x litellm/deploy/docker/docker-entrypoint.sh RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \
sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh
RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \
cp -r litellm/proxy/_experimental/out/. /var/lib/litellm/ui/ && \
cp litellm/proxy/logo.png /var/lib/litellm/assets/logo.png && \
touch /var/lib/litellm/ui/.litellm_ui_ready
FROM $LITELLM_BUILD_IMAGE AS liteadmin-builder FROM $LITELLM_BUILD_IMAGE AS liteadmin-builder
COPY --from=uvbin /uv /usr/local/bin/uv COPY --from=uvbin /uv /usr/local/bin/uv
@ -155,66 +145,46 @@ USER root
# https://github.com/BerriAI/litellm/issues/33518 # https://github.com/BerriAI/litellm/issues/33518
RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories
RUN for i in 1 2 3; do \ # node (without npm) is required by the prisma CLI at runtime
apk add --no-cache bash openssl libssl3 tzdata nodejs python-3.13 libsndfile libatomic libevent nginx && break; \ RUN apk add --no-cache bash openssl libssl3 tzdata nodejs python-3.13 libsndfile libevent
[ "$i" = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \
sleep 5; \
done
COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer
WORKDIR /app WORKDIR /app
ENV PATH="/app/.venv/bin:${PATH}" \ ENV PATH="/app/.venv/bin:${PATH}" \
PYTHONPATH="/app" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
HOME=/app \
LITELLM_NON_ROOT=true \
PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \ PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \ PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \ PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_SKIP_POSTINSTALL_GENERATE=1 \
PRISMA_HIDE_UPDATE_MESSAGE=1 \
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 \
PRISMA_OFFLINE_MODE=true PRISMA_OFFLINE_MODE=true
# Copy only what runtime needs. The application is installed inside the venv; # 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 # the rest of the builder's /app is source and build metadata that must not
# ship (manifest-scanning tools attribute everything in it to this image). # ship (manifest-scanning tools attribute everything in it to this image).
# entrypoint.sh invokes litellm/proxy/prisma_migration.py by source path.
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/.venv /app/.venv
COPY --from=liteadmin-builder /opt/liteadmin /opt/liteadmin COPY --from=liteadmin-builder /opt/liteadmin /opt/liteadmin
COPY --from=builder /app/litellm/deploy/docker/docker-entrypoint.sh /app/docker-entrypoint.sh COPY --from=builder /app/docker /app/docker
COPY --from=builder /app/litellm/schema.prisma /app/litellm/schema.prisma COPY --from=builder /app/schema.prisma /app/schema.prisma
COPY --from=builder /app/litellm/schema.prisma /app/schema.prisma
COPY --from=builder /app/litellm/runtime/gateway /app/gateway
COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/prisma_migration.py COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/prisma_migration.py
# enterprise/ is imported by source path at runtime (proxy_cli puts the # enterprise/ is imported by source path at runtime (proxy_cli puts the
# working directory on sys.path; litellm/proxy/hooks resolves # working directory on sys.path; litellm/proxy/hooks resolves
# litellm.enterprise.enterprise_hooks from it) # enterprise.enterprise_hooks from it)
COPY --from=builder /app/enterprise /app/enterprise
COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras
# Prisma CLI + engines are baked under /opt/prisma, a fixed path every
# runtime uid can read and that no cache volume mount shadows. The paths are
# pinned via PRISMA_BINARY_CACHE_DIR / PRISMA_CLI_PATH and recorded into the
# generated client at build time, so `prisma migrate deploy` on a fresh
# database needs no npm and no network access (#33650, #24554).
COPY --from=builder /opt/prisma /opt/prisma COPY --from=builder /opt/prisma /opt/prisma
COPY --from=builder /var/lib/litellm /var/lib/litellm
COPY litellm/ui/nginx.conf /etc/nginx/nginx.conf
COPY litellm/ui/docker-entrypoint.d/40-litellm-runtime-config.sh /app/ui/docker-entrypoint.d/40-litellm-runtime-config.sh
RUN find /app/.venv -depth -type d -path "*/tornado/test" -exec rm -rf {} + && \ RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \
mkdir -p /app/.cache /var/run/litellm && \ find /app/.venv -type d -path "*/tornado/test" -delete && \
chown -R 65532:0 /app /var/lib/litellm /var/run/litellm && \
chmod -R g=u,g+w /app/.cache /var/lib/litellm /var/run/litellm && \
PRISMA_PATH="$(python -c 'import os, prisma; print(os.path.dirname(prisma.__file__))')" && \
PROXY_EXTRAS_PATH="$(python -c 'import os, litellm_proxy_extras; print(os.path.dirname(litellm_proxy_extras.__file__))')" && \
chmod -R g=u,g+w "$PRISMA_PATH" "$PROXY_EXTRAS_PATH" && \
chmod -R a+rX /opt/prisma && \ chmod -R a+rX /opt/prisma && \
test -x /opt/prisma/binaries/node_modules/.bin/prisma && \ test -x /opt/prisma/binaries/node_modules/.bin/prisma && \
test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \ test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \
ls /opt/prisma/binaries/node_modules/@prisma/engines/query-engine-* >/dev/null && \ 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 "from litellm.rust_bridge import _native" && \
python -c "import gateway.launch, litellm.runtime.backend.main"
USER 65532:65532 EXPOSE 4000/tcp
RUN nginx -t ENTRYPOINT ["docker/prod_entrypoint.sh"]
CMD ["--port", "4000"]
EXPOSE 4000/tcp 4001/tcp 3000/tcp
ENTRYPOINT ["/app/docker-entrypoint.sh"]

1
GEMINI.md Normal file
View file

@ -0,0 +1 @@
Read @AGENTS.md for coding guidelines

View file

@ -1,6 +1,6 @@
Portions of this software are licensed as follows: Portions of this software are licensed as follows:
* All content that resides under the "litellm/enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "litellm/enterprise/LICENSE". * All content that resides under the "enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "enterprise/LICENSE".
* Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below. * Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below.
--- ---
MIT License MIT License

View file

@ -54,24 +54,20 @@ help:
@echo " make test-integration - Run integration tests" @echo " make test-integration - Run integration tests"
@echo " make test-unit-helm - Run helm unit tests" @echo " make test-unit-helm - Run helm unit tests"
@echo " make test-rust-extension - Build the Rust extension and run its public Python tests" @echo " make test-rust-extension - Build the Rust extension and run its public Python tests"
@echo " make rust-sqlx-prepare - Refresh litellm/rust/crates/db/.sqlx against a migrated Postgres container" @echo " make rust-sqlx-prepare - Refresh litellm-rust/crates/db/.sqlx against a migrated Postgres container"
@echo "" @echo ""
@echo "Heavy targets (check, lint) queue for LITELLM_GATE_SLOTS machine-wide" @echo "Heavy targets (check, lint) queue for LITELLM_GATE_SLOTS machine-wide"
@echo "slots (default 2; 0 disables) so parallel sessions don't thrash one machine." @echo "slots (default 2; 0 disables) so parallel sessions don't thrash one machine."
UV := uv UV := uv
export RUSTUP_TOOLCHAIN ?= $(shell sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' litellm/rust/rust-toolchain.toml)
export CARGO_NET_RETRY ?= 5
export CARGO_HTTP_MULTIPLEXING ?= false
export SQLX_OFFLINE ?= true
UV_RUN := $(UV) run --no-sync UV_RUN := $(UV) run --no-sync
BASE_REF ?= BASE_REF ?=
export BASE_REF export BASE_REF
RESOLVE_BASE = python3 litellm/scripts/default_branch.py --base "$(BASE_REF)" RESOLVE_BASE = python3 scripts/default_branch.py --base "$(BASE_REF)"
# Machine-wide slot queue for the heavy targets below; python3 + stdlib only, so # Machine-wide slot queue for the heavy targets below; python3 + stdlib only, so
# it runs before any venv exists. See litellm/scripts/gate_slot_lock.py. # it runs before any venv exists. See scripts/gate_slot_lock.py.
GATE_SLOT_LOCK := python3 litellm/scripts/gate_slot_lock.py GATE_SLOT_LOCK := python3 scripts/gate_slot_lock.py
LINT_DEP_INSTALL ?= install-dev LINT_DEP_INSTALL ?= install-dev
LINT_E2E_DEP_INSTALL ?= lint-install LINT_E2E_DEP_INSTALL ?= lint-install
@ -94,8 +90,8 @@ install-dev:
# machine-wide slots the CPU-bound gates below share. # machine-wide slots the CPU-bound gates below share.
bootstrap: bootstrap:
$(UV) sync --inexact --frozen --extra proxy --group proxy-dev --group e2e-dev $(UV) sync --inexact --frozen --extra proxy --group proxy-dev --group e2e-dev
$(UV_RUN) python litellm/scripts/prisma_generate_if_needed.py $(UV_RUN) python scripts/prisma_generate_if_needed.py
cd litellm/ui/litellm-dashboard && ../../scripts/with_dashboard_node.sh npm install --no-audit --no-fund cd ui/litellm-dashboard && ../../scripts/with_dashboard_node.sh npm install --no-audit --no-fund
@main_root=$$(git worktree list --porcelain | head -1 | sed 's/^worktree //'); \ @main_root=$$(git worktree list --porcelain | head -1 | sed 's/^worktree //'); \
if [ "$$main_root" != "$$(git rev-parse --show-toplevel)" ] && [ -f "$$main_root/.env" ] && [ ! -f .env ]; then \ if [ "$$main_root" != "$$(git rev-parse --show-toplevel)" ] && [ -f "$$main_root/.env" ] && [ ! -f .env ]; then \
cp "$$main_root/.env" .env && echo "bootstrap: copied .env from $$main_root"; \ cp "$$main_root/.env" .env && echo "bootstrap: copied .env from $$main_root"; \
@ -127,10 +123,10 @@ install-helm-unittest:
# Install git hooks that enforce Conventional Commits. # Install git hooks that enforce Conventional Commits.
# Opt-in: not chained into install-dev. # Opt-in: not chained into install-dev.
install-hooks: install-hooks:
./litellm/scripts/install_git_hooks.sh ./scripts/install_git_hooks.sh
# Formatting # Formatting
# Wrap width is pyproject.toml's single source of truth (line-length = 120), shared by the # Wrap width is ruff.toml's single source of truth (line-length = 120), shared by the
# formatter and the import sorter so there's no 88-vs-120 split to reconcile. # formatter and the import sorter so there's no 88-vs-120 split to reconcile.
format: install-dev format: install-dev
cd litellm && $(UV_RUN) ruff format --exclude '/enterprise/' . && cd .. cd litellm && $(UV_RUN) ruff format --exclude '/enterprise/' . && cd ..
@ -143,12 +139,12 @@ lint-fetch-base:
# Mirror test-linting.yml's python job environment: the proxy-dev group plus a generated # Mirror test-linting.yml's python job environment: the proxy-dev group plus a generated
# Prisma client, so `basedpyright tests/e2e` resolves the same modules CI does. The # Prisma client, so `basedpyright tests/e2e` resolves the same modules CI does. The
# basedpyright gate itself no longer measures here (litellm/scripts/type_check_gate.py provisions its # basedpyright gate itself no longer measures here (scripts/type_check_gate.py provisions its
# own .venv-typecheck). --inexact tops up the venv instead of pruning the proxy extras # own .venv-typecheck). --inexact tops up the venv instead of pruning the proxy extras
# gen:api and the running proxy need. # gen:api and the running proxy need.
lint-install: lint-install:
$(UV) sync --inexact --frozen --group proxy-dev --group e2e-dev $(UV) sync --inexact --frozen --group proxy-dev --group e2e-dev
$(UV_RUN) python litellm/scripts/prisma_generate_if_needed.py $(UV_RUN) python scripts/prisma_generate_if_needed.py
# Diff-scoped format check, mirroring test-linting.yml's "Check ruff format" step: # 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 # only the litellm Python files changed vs the base are checked, so a pre-existing
@ -167,9 +163,9 @@ lint-format-check-changed: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
# Linting targets # Linting targets
lint-ruff: $(LINT_DEP_INSTALL) lint-ruff: $(LINT_DEP_INSTALL)
$(UV_RUN) python litellm/scripts/check_mcp_operation_boundary.py $(UV_RUN) python scripts/check_mcp_operation_boundary.py
cd litellm && $(UV_RUN) ruff check . && cd .. cd litellm && $(UV_RUN) ruff check . && cd ..
$(UV_RUN) ruff check --config .github/linters/ruff-tests.toml tests $(UV_RUN) ruff check --config ruff-tests.toml tests
# faster linter for developing ... # faster linter for developing ...
# inspiration from: # inspiration from:
@ -208,7 +204,7 @@ lint-ruff-FULL-dev: install-dev
else echo "No changed .py files to check."; fi else echo "No changed .py files to check."; fi
lint-basedpyright: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) lint-basedpyright: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python litellm/scripts/type_check_gate.py --base "$(BASE_REF)" $(UV_RUN) python scripts/type_check_gate.py --base "$(BASE_REF)"
lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL) lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL)
$(UV_RUN) basedpyright tests/e2e tests/e2e_harness $(UV_RUN) basedpyright tests/e2e tests/e2e_harness
@ -216,29 +212,29 @@ lint-e2e-basedpyright: $(LINT_E2E_DEP_INSTALL)
# Type-discipline gate (mutable collections / casts / type guards / kwargs / # Type-discipline gate (mutable collections / casts / type guards / kwargs /
# unexplained suppressions), the test-linting.yml step `make lint` used to omit. # unexplained suppressions), the test-linting.yml step `make lint` used to omit.
lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python litellm/scripts/type_discipline_gate.py --base "$(BASE_REF)" $(UV_RUN) python scripts/type_discipline_gate.py --base "$(BASE_REF)"
# Test-quality gate (zero-assert / mock-echo tests, sys.path.insert, raw env writes, # Test-quality gate (zero-assert / mock-echo tests, sys.path.insert, raw env writes,
# litellm module-global mutation, credential-gated skips, conftest snapshot # litellm module-global mutation, credential-gated skips, conftest snapshot
# inventory), counted across tests/ the same delta-vs-base way. # inventory), counted across tests/ the same delta-vs-base way.
lint-test-quality: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) lint-test-quality: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python litellm/scripts/test_quality_gate.py --base "$(BASE_REF)" $(UV_RUN) python scripts/test_quality_gate.py --base "$(BASE_REF)"
# OpenAPI docs gate (POST routes without a request body schema, a description or a # OpenAPI docs gate (POST routes without a request body schema, a description or a
# 200 schema in app.openapi()), counted the same delta-vs-base way so a new endpoint # 200 schema in app.openapi()), counted the same delta-vs-base way so a new endpoint
# has to show up documented in Swagger UI. # has to show up documented in Swagger UI.
lint-openapi-docs: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) lint-openapi-docs: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python litellm/scripts/openapi_docs_gate.py --base "$(BASE_REF)" $(UV_RUN) python scripts/openapi_docs_gate.py --base "$(BASE_REF)"
lint-format: format-check lint-format: format-check
lint-ruff-strict: install-dev lint-ruff-strict: install-dev
$(UV_RUN) python litellm/scripts/ruff_strict_gate.py --base "$(BASE_REF)" $(UV_RUN) python scripts/ruff_strict_gate.py --base "$(BASE_REF)"
# Strict gate, invoked the same way CI does in test-linting.yml so a local pass # Strict gate, invoked the same way CI does in test-linting.yml so a local pass
# means the CI check will pass too. # means the CI check will pass too.
lint-gate: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE) lint-gate: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python litellm/scripts/ruff_strict_gate.py --base "$(BASE_REF)" $(UV_RUN) python scripts/ruff_strict_gate.py --base "$(BASE_REF)"
check-circular-imports: $(LINT_DEP_INSTALL) check-circular-imports: $(LINT_DEP_INSTALL)
cd litellm && $(UV_RUN) python ../tests/documentation_tests/test_circular_imports.py && cd .. cd litellm && $(UV_RUN) python ../tests/documentation_tests/test_circular_imports.py && cd ..
@ -276,7 +272,7 @@ check:
@$(GATE_SLOT_LOCK) $(MAKE) check-inner @$(GATE_SLOT_LOCK) $(MAKE) check-inner
check-inner: bootstrap check-inner: bootstrap
./litellm/scripts/pre_commit_lint.sh ./scripts/pre_commit_lint.sh
pre-commit: pre-commit:
@echo "make pre-commit is a legacy alias; use make check" >&2 @echo "make pre-commit is a legacy alias; use make check" >&2
@ -298,7 +294,7 @@ test-rust-extension:
"$$temporary/venv/bin/python" -I -m pytest --import-mode=importlib -m requires_rust_extension tests/test_litellm_rust "$$temporary/venv/bin/python" -I -m pytest --import-mode=importlib -m requires_rust_extension tests/test_litellm_rust
rust-sqlx-prepare: rust-sqlx-prepare:
cd litellm/rust && cargo run -p litellm-db-testing --bin sqlx-prepare cd litellm-rust && cargo run -p litellm-db-testing --bin sqlx-prepare
test: install-test-deps test: install-test-deps
$(UV_RUN) pytest tests/ $(UV_RUN) pytest tests/
@ -345,7 +341,7 @@ test-integration: install-test-deps
$(UV_RUN) pytest tests/ $(UV_RUN) pytest tests/
test-unit-helm: install-helm-unittest test-unit-helm: install-helm-unittest
helm unittest -f 'tests/*.yaml' litellm/deploy/helm/litellm helm unittest -f 'tests/*.yaml' helm/litellm-helm
# LLM Translation testing targets # LLM Translation testing targets
test-llm-translation: install-test-deps test-llm-translation: install-test-deps

View file

@ -6,7 +6,7 @@
</p> </p>
<p align="center">Open Source AI Gateway for 100+ LLMs. Self-hosted. Enterprise-ready. Call any LLM in OpenAI format.</p> <p align="center">Open Source AI Gateway for 100+ LLMs. Self-hosted. Enterprise-ready. Call any LLM in OpenAI format.</p>
<p align="center"> <p align="center">
<a href="https://render.com/deploy?repo=https://github.com/BerriAI/litellm&amp;blueprintPath=litellm/deploy/render/render.yaml" target="_blank" rel="nofollow"><img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" height="40"></a> <a href="https://render.com/deploy?repo=https://github.com/BerriAI/litellm" target="_blank" rel="nofollow"><img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" height="40"></a>
<a href="https://railway.com/deploy/RhvhdC?referralCode=7mRv9K&utm_medium=integration&utm_source=template&utm_campaign=generic"><img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40"></a> <a href="https://railway.com/deploy/RhvhdC?referralCode=7mRv9K&utm_medium=integration&utm_source=template&utm_campaign=generic"><img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40"></a>
<a href="https://console.aws.amazon.com/cloudshell/home" target="_blank" rel="nofollow"><img src="./.github/deploy-on-aws.png" alt="Deploy on AWS" height="40"></a> <a href="https://console.aws.amazon.com/cloudshell/home" target="_blank" rel="nofollow"><img src="./.github/deploy-on-aws.png" alt="Deploy on AWS" height="40"></a>
<a href="https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Fterraform-google-litellm&cloudshell_git_branch=v1.105.0&cloudshell_workspace=examples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true" target="_blank" rel="nofollow"><img src="./.github/deploy-on-gcp.png" alt="Deploy on GCP" height="40"></a> <a href="https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Fterraform-google-litellm&cloudshell_git_branch=v1.105.0&cloudshell_workspace=examples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true" target="_blank" rel="nofollow"><img src="./.github/deploy-on-gcp.png" alt="Deploy on GCP" height="40"></a>
@ -99,7 +99,7 @@ proxy, CLI, and optional extras
Build and install core from this checkout while its release integration is pending: Build and install core from this checkout while its release integration is pending:
```shell ```shell
python litellm/scripts/build_core_distribution.py --out-dir dist/core python scripts/build_core_distribution.py --out-dir dist/core
python -m pip install dist/core/litellm_core-*.whl python -m pip install dist/core/litellm_core-*.whl
``` ```
@ -116,7 +116,7 @@ os.environ["OPENAI_API_KEY"] = "your-openai-key"
os.environ["ANTHROPIC_API_KEY"] = "your-anthropic-key" os.environ["ANTHROPIC_API_KEY"] = "your-anthropic-key"
# OpenAI # OpenAI
response = completion(model="openai/gpt-6.1-sol", messages=[{"role": "user", "content": "Hello!"}]) response = completion(model="openai/gpt-4o", messages=[{"role": "user", "content": "Hello!"}])
# Anthropic # Anthropic
response = completion(model="anthropic/claude-sonnet-4-20250514", messages=[{"role": "user", "content": "Hello!"}]) response = completion(model="anthropic/claude-sonnet-4-20250514", messages=[{"role": "user", "content": "Hello!"}])
@ -128,7 +128,7 @@ response = completion(model="anthropic/claude-sonnet-4-20250514", messages=[{"ro
```shell ```shell
uv tool install 'litellm[proxy]' uv tool install 'litellm[proxy]'
litellm --model gpt-6.1-sol litellm --model gpt-4o
``` ```
```python ```python
@ -136,7 +136,7 @@ import openai
client = openai.OpenAI(api_key="anything", base_url="http://0.0.0.0:4000") client = openai.OpenAI(api_key="anything", base_url="http://0.0.0.0:4000")
response = client.chat.completions.create( response = client.chat.completions.create(
model="gpt-6.1-sol", model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}] messages=[{"role": "user", "content": "Hello!"}]
) )
``` ```
@ -281,7 +281,7 @@ async with stdio_client(server_params) as (read, write):
# Use with any LiteLLM model # Use with any LiteLLM model
response = await litellm.acompletion( response = await litellm.acompletion(
model="gpt-6.1-sol", model="gpt-4o",
messages=[{"role": "user", "content": "What's 3 + 5?"}], messages=[{"role": "user", "content": "What's 3 + 5?"}],
tools=tools tools=tools
) )
@ -298,7 +298,7 @@ curl -X POST 'http://0.0.0.0:4000/v1/chat/completions' \
-H 'Authorization: Bearer <your-master-key>' \ -H 'Authorization: Bearer <your-master-key>' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{ -d '{
"model": "gpt-6.1-sol", "model": "gpt-4o",
"messages": [{"role": "user", "content": "Summarize the latest open PR"}], "messages": [{"role": "user", "content": "Summarize the latest open PR"}],
"tools": [{ "tools": [{
"type": "mcp", "type": "mcp",
@ -526,7 +526,7 @@ Run the LiteLLM proxy as a production-ready componentized stack (gateway, backen
```bash ```bash
git clone https://github.com/BerriAI/litellm.git git clone https://github.com/BerriAI/litellm.git
cd litellm/litellm/deploy/terraform/litellm/aws/examples/default cd litellm/terraform/litellm/aws/examples/default
cp terraform.tfvars.example terraform.tfvars # edit region/tenant/env cp terraform.tfvars.example terraform.tfvars # edit region/tenant/env
terraform init && terraform apply terraform init && terraform apply
``` ```
@ -579,7 +579,7 @@ Provider API keys live in AWS Secrets Manager; reference ARNs via `gateway_extra
[![Open in Cloud Shell](https://gstatic.com/cloudssh/images/open-btn.png)](https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Fterraform-google-litellm&cloudshell_git_branch=v1.105.0&cloudshell_workspace=examples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true) [![Open in Cloud Shell](https://gstatic.com/cloudssh/images/open-btn.png)](https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Fterraform-google-litellm&cloudshell_git_branch=v1.105.0&cloudshell_workspace=examples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true)
Real 1-click. Opens Cloud Shell, clones the [terraform-google-litellm](https://github.com/BerriAI/terraform-google-litellm) module mirror at the latest stable release (a small download instead of the full litellm repo), and walks you through `terraform apply` via a built-in [DeployStack tutorial](./litellm/deploy/terraform/litellm/gcp/examples/default/TUTORIAL.md). Pick the project, the tutorial sets up the Artifact Registry remote repo, writes `terraform.tfvars` from your answers, and runs apply Real 1-click. Opens Cloud Shell, clones the [terraform-google-litellm](https://github.com/BerriAI/terraform-google-litellm) module mirror at the latest stable release (a small download instead of the full litellm repo), and walks you through `terraform apply` via a built-in [DeployStack tutorial](./terraform/litellm/gcp/examples/default/TUTORIAL.md). Pick the project, the tutorial sets up the Artifact Registry remote repo, writes `terraform.tfvars` from your answers, and runs apply
[Module page →](https://registry.terraform.io/modules/BerriAI/litellm/google/latest) [Module page →](https://registry.terraform.io/modules/BerriAI/litellm/google/latest)
@ -646,21 +646,21 @@ Provider API keys live in Secret Manager; reference resource IDs (e.g. `projects
- Versioned object store for proxy state + file uploads - Versioned object store for proxy state + file uploads
- An auto-generated `LITELLM_MASTER_KEY` in your cloud's secret manager - An auto-generated `LITELLM_MASTER_KEY` in your cloud's secret manager
- A one-off migration job that runs `prisma migrate deploy` before the proxy starts - A one-off migration job that runs `prisma migrate deploy` before the proxy starts
- The same `proxy_config` surface as the [Helm chart](./litellm/deploy/helm/litellm/) — pass YAML as a typed map - The same `proxy_config` surface as the [Helm chart](./helm/litellm/) — pass YAML as a typed map
The Terraform modules live at [`litellm/deploy/terraform/litellm/aws/`](./litellm/deploy/terraform/litellm/aws/) and [`litellm/deploy/terraform/litellm/gcp/`](./litellm/deploy/terraform/litellm/gcp/) in this repo; the registry entries are read-only mirrors updated on each release. The Terraform modules live at [`terraform/litellm/aws/`](./terraform/litellm/aws/) and [`terraform/litellm/gcp/`](./terraform/litellm/gcp/) in this repo; the registry entries are read-only mirrors updated on each release.
### Run in Developer Mode ### Run in Developer Mode
#### Services #### Services
1. Setup .env file in root 1. Setup .env file in root
2. Run dependent services `docker compose -f litellm/deploy/compose/docker-compose.yml up db prometheus` 2. Run dependent services `docker-compose up db prometheus`
#### Backend #### Backend
1. Run `make bootstrap` 1. Run `make bootstrap`
2. Start proxy backend: `uv run python litellm/proxy/proxy_cli.py` 2. Start proxy backend: `uv run python litellm/proxy/proxy_cli.py`
#### Frontend #### Frontend
1. Navigate to `litellm/ui/litellm-dashboard` (dependencies were already installed w/ `make bootstrap`) 1. Navigate to `ui/litellm-dashboard` (dependencies were already installed w/ `make bootstrap`)
2. Start dashboard: `npm run dev` 2. Start dashboard: `npm run dev`
### Verify Docker Image Signatures ### Verify Docker Image Signatures
@ -725,7 +725,7 @@ make test-unit # Run unit tests
make format-check # Check formatting only make format-check # Check formatting only
``` ```
For detailed contributing guidelines, see [CONTRIBUTING.md](.github/CONTRIBUTING.md). For detailed contributing guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md).
> **📖 Contributing to documentation?** The LiteLLM docs have moved to a separate repository: [BerriAI/litellm-docs](https://github.com/BerriAI/litellm-docs). Please open doc PRs there. Docs are served at [docs.litellm.ai](https://docs.litellm.ai). > **📖 Contributing to documentation?** The LiteLLM docs have moved to a separate repository: [BerriAI/litellm-docs](https://github.com/BerriAI/litellm-docs). Please open doc PRs there. Docs are served at [docs.litellm.ai](https://docs.litellm.ai).

View file

@ -4,19 +4,23 @@
LiteLLM maintains a security announcements mailing list that is open to anyone. Subscribers receive advance notice, typically one to two days, before we release a fix for a particularly severe vulnerability or for any vulnerability exploitable by an unauthenticated attacker. This notice is provided on a best-effort basis LiteLLM maintains a security announcements mailing list that is open to anyone. Subscribers receive advance notice, typically one to two days, before we release a fix for a particularly severe vulnerability or for any vulnerability exploitable by an unauthenticated attacker. This notice is provided on a best-effort basis
To subscribe, visit [https://security.litellm.ai/mailinglist/](https://security.litellm.ai/mailinglist/) To subscribe, visit [https://berriai.github.io/security-announce-signup/](https://berriai.github.io/security-announce-signup/)
## Security Vulnerability Reporting Guidelines ## Security Vulnerability Reporting Guidelines
> [!WARNING]
> Reports that do not include a video demonstrating the exploit will be closed without review. See [Reproduction Video Requirement](#reproduction-video-requirement) below.
We value the security community's role in protecting our systems and users. We value the security community's role in protecting our systems and users. To report a security vulnerability:
To report a security vulnerability, please submit a report through our vulnerability disclosure form:
[LiteLLM VDP](https://security.litellm.ai/vdp/) - File a private vulnerability report on GitHub: [Report a vulnerability](https://github.com/BerriAI/litellm/security/advisories/new)
- Include steps to reproduce the issue
- Include a video or screen recording demonstrating the full exploit against a live LiteLLM instance, from initial access through to impact. A terminal recording (for example asciinema) is fine for CLI-only exploits.
- Provide any relevant additional information
These reports are submitted to HackerOne and are triaged there, though no account is needed. ### Reproduction Video Requirement
**Please do not submit vulnerabilities as draft GHSAs. As of 2026-11-11 new GHSA submissions will be closed.** A video demonstrating the exploit is required for every report. AI tools have made it easy to produce plausible-sounding vulnerability reports that do not reproduce in practice, and triaging them takes time away from real issues. Reports submitted without a working reproduction video will be closed without review. If you add a video to a closed report, we will reopen and triage it.
### Vulnerability Categories ### Vulnerability Categories
@ -51,9 +55,7 @@ To qualify for a bounty, reports must include clear reproduction steps, a reprod
### Known Non-Issues ### Known Non-Issues
- **Misconfigurations:** attacks that require misconfiguration of the application (e.g. not setting a `master_key` on the proxy configuration). - Attacks that require a misconfiguration on setup (e.g not setting a `master_key` on the proxy configuration), are **explicitly not in scope** and are not considered vulnerable.
- **Insecure configurations:** attacks that require deliberate configuration changes to disable security functionality. (e.g. IDOR on response IDs requiring configuration of `disable_responses_id_security` to `true`)
- **Social engineering:** attacks that rely on social engineering as their primary mechanism (e.g. convincing a user's agent to install a malicious package we did not publish).
## Security Measures ## Security Measures

146
backend/Dockerfile Normal file
View file

@ -0,0 +1,146 @@
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:360896efb0753a5701c48f860e17130c205eae5a264797aa3e7ea126ee2923a7
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
FROM $UV_IMAGE AS uvbin
# ---------- Rust extension ----------
# Copies only the native build's inputs, so the fat-LTO compile stays cached across Python-only changes
FROM $LITELLM_BUILD_IMAGE AS rust-native
WORKDIR /app
USER root
COPY --from=uvbin /uv /uvx /usr/local/bin/
RUN for i in 1 2 3; do \
apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl openssl-dev && break; \
[ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \
sleep 5; \
done
ENV UV_PYTHON_DOWNLOADS=0
COPY pyproject.toml README.md rust-toolchain.toml ./
COPY .cargo ./.cargo
COPY litellm-rust ./litellm-rust
COPY model_prices_and_context_window.json ./
RUN --mount=type=cache,target=/root/.cache/uv \
mkdir -p litellm/rust_bridge /wheels && \
touch litellm/__init__.py litellm/rust_bridge/__init__.py && \
uvx --python python3.13 --from maturin==1.15.0 --with puccinialin==0.1.14 \
python -c "import maturin; maturin.build_wheel('/wheels')"
# ---------- Builder ----------
FROM $LITELLM_BUILD_IMAGE AS builder
WORKDIR /app
USER root
COPY --from=uvbin /uv /uvx /usr/local/bin/
# nodejs/npm so `prisma generate` uses Wolfi's Node via PRISMA_USE_GLOBAL_NODE
# instead of nodeenv downloading one whose dynamic deps may not be in Wolfi
# (e.g. Node 26.2.0 needs libatomic). Retry for transient apk.cgr.dev flakes.
RUN for i in 1 2 3; do \
apk add --no-cache bash gcc python-3.13 python-3.13-dev openssl libssl3 openssl-dev libsndfile nodejs npm && break; \
[ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \
sleep 5; \
done
# UV_COMPILE_BYTECODE=1 precompiles .pyc at install time → faster cold start.
# UV_LINK_MODE=copy avoids hardlink warnings when uv installs from a
# BuildKit cache mount (different filesystem).
# UV_PYTHON_DOWNLOADS=0 force uv to use the apk-installed CPython instead of
# silently pulling a managed interpreter.
# PRISMA_USE_GLOBAL_NODE explicit (matches default) so an env override can't
# silently re-enable nodeenv's Node download.
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PYTHON_DOWNLOADS=0 \
PRISMA_USE_GLOBAL_NODE=true \
PATH="/app/.venv/bin:${PATH}"
# Stage 1 — install dependencies only.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=enterprise/pyproject.toml,target=enterprise/pyproject.toml \
--mount=type=bind,source=litellm-proxy-extras/pyproject.toml,target=litellm-proxy-extras/pyproject.toml \
uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--group admin-mcp \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--python python3.13
# Stage 2 — copy source and install the project + workspace members.
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--group admin-mcp \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--python python3.13
RUN --mount=type=bind,from=rust-native,source=/wheels,target=/wheels \
uv pip install --python /app/.venv/bin/python --no-deps /wheels/litellm-*.whl
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
RUN sed -i 's/\r$//' docker/component_entrypoint.sh && chmod +x docker/component_entrypoint.sh
# ---------- Runtime ----------
FROM $LITELLM_RUNTIME_IMAGE AS runtime
ARG LITELLM_RELEASE_TAG=""
ENV LITELLM_RELEASE_TAG=${LITELLM_RELEASE_TAG}
USER root
RUN for i in 1 2 3; do \
apk add --no-cache bash openssl libssl3 tzdata python-3.13 libsndfile libatomic && break; \
[ $i = 3 ] && { echo "apk add failed after 3 retries" >&2; exit 1; }; \
sleep 5; \
done
# wolfi-base ships an unprivileged `nonroot` account (UID/GID 65532) with
# /home/nonroot. We run the backend as that user
WORKDIR /app
ENV HOME=/home/nonroot \
PATH="/app/.venv/bin:${PATH}" \
PYTHONPATH="/app" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries
COPY --from=builder --chown=nonroot:nonroot /app/.venv /app/.venv
COPY --from=builder --chown=nonroot:nonroot /app/litellm /app/litellm
COPY --from=builder --chown=nonroot:nonroot /app/enterprise /app/enterprise
COPY --from=builder --chown=nonroot:nonroot /app/backend /app/backend
COPY --from=builder --chown=nonroot:nonroot /app/docker/component_entrypoint.sh /app/docker/component_entrypoint.sh
COPY --from=builder --chown=nonroot:nonroot /app/schema.prisma /app/schema.prisma
COPY --from=builder /opt/prisma /opt/prisma
RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \
find /app/.venv -type d -path "*/tornado/test" -delete && \
chmod -R a+rX /opt/prisma && \
python -c "from prisma.client import BINARY_PATHS; paths = list(BINARY_PATHS.query_engine.values()); assert paths and all(p.startswith('/opt/prisma/') for p in paths), paths"
USER nonroot
EXPOSE 4001/tcp
ENTRYPOINT ["/app/docker/component_entrypoint.sh", "uvicorn", "backend.main:app"]
CMD ["--host", "0.0.0.0", "--port", "4001"]

View file

@ -5,7 +5,7 @@ route table to just the management/admin surface used by the dashboard. Purely
additive — no existing module is modified. additive — no existing module is modified.
Run with: Run with:
uvicorn litellm.runtime.backend.main:app --host 0.0.0.0 --port 4001 uvicorn backend.main:app --host 0.0.0.0 --port 4001
""" """
from collections.abc import AsyncGenerator, Mapping from collections.abc import AsyncGenerator, Mapping
@ -16,7 +16,7 @@ from starlette.applications import Starlette
from starlette.routing import Mount from starlette.routing import Mount
from starlette.types import Lifespan from starlette.types import Lifespan
# See litellm/runtime/gateway/main.py for why we assemble DATABASE_URL(s) here before # See gateway/main.py for why we assemble DATABASE_URL(s) here before
# importing proxy_server. # importing proxy_server.
from litellm.proxy.db.db_url_settings import DatabaseURLSettings from litellm.proxy.db.db_url_settings import DatabaseURLSettings
@ -24,7 +24,7 @@ DatabaseURLSettings.from_env().apply_to_env()
from litellm.proxy.proxy_server import app from litellm.proxy.proxy_server import app
from litellm.runtime.backend.routes.allowlist import ( from backend.routes.allowlist import (
BACKEND_EXACT_PATHS, BACKEND_EXACT_PATHS,
BACKEND_MOUNT_PATHS, BACKEND_MOUNT_PATHS,
BACKEND_PATH_PREFIXES, BACKEND_PATH_PREFIXES,
@ -45,7 +45,7 @@ def _is_backend_route(route) -> bool:
return any(path.startswith(prefix) for prefix in BACKEND_PATH_PREFIXES) return any(path.startswith(prefix) for prefix in BACKEND_PATH_PREFIXES)
# See litellm/runtime/gateway/main.py for why the trim runs inside the lifespan instead of at # See gateway/main.py for why the trim runs inside the lifespan instead of at
# module scope. # module scope.
_proxy_lifespan: Final = app.router.lifespan_context _proxy_lifespan: Final = app.router.lifespan_context

View file

@ -110,7 +110,6 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/v1/rust_control_plane/", "/v1/rust_control_plane/",
# Alerting / email / IP allowlist # Alerting / email / IP allowlist
"/alerting/", "/alerting/",
"/alerts/",
"/email/", "/email/",
"/add/allowed_ip", "/add/allowed_ip",
"/delete/allowed_ip", "/delete/allowed_ip",
@ -155,7 +154,6 @@ BACKEND_EXACT_PATHS: frozenset[str] = frozenset(
"/", "/",
"/routes", "/routes",
"/lens", "/lens",
"/alerts",
"/openapi.json", "/openapi.json",
"/docs", "/docs",
"/docs/oauth2-redirect", "/docs/oauth2-redirect",

View file

@ -33,7 +33,7 @@ token = "test-api-key-with-high-entropy"
## Configured Ignore Patterns ## Configured Ignore Patterns
These patterns are in `.github/gitguardian.yaml` for high-entropy test keys: These patterns are in `.gitguardian.yaml` for high-entropy test keys:
- `sk-test-*` - OpenAI-style test keys - `sk-test-*` - OpenAI-style test keys
- `sk-mock-*` - Mock API keys - `sk-mock-*` - Mock API keys
- `sk-fake-*` - Fake API keys - `sk-fake-*` - Fake API keys

60
ci_cd/baseline_db.py Normal file
View file

@ -0,0 +1,60 @@
import subprocess
from pathlib import Path
from datetime import datetime
def create_baseline():
"""Create baseline migration in deploy/migrations"""
try:
# Get paths
root_dir = Path(__file__).parent.parent
deploy_dir = root_dir / "deploy"
migrations_dir = deploy_dir / "migrations"
schema_path = root_dir / "schema.prisma"
# Create migrations directory
migrations_dir.mkdir(parents=True, exist_ok=True)
# Create migration_lock.toml if it doesn't exist
lock_file = migrations_dir / "migration_lock.toml"
if not lock_file.exists():
lock_file.write_text('provider = "postgresql"\n')
# Create timestamp-based migration directory
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
migration_dir = migrations_dir / f"{timestamp}_baseline"
migration_dir.mkdir(parents=True, exist_ok=True)
# Generate migration SQL
result = subprocess.run(
[
"prisma",
"migrate",
"diff",
"--from-empty",
"--to-schema-datamodel",
str(schema_path),
"--script",
],
capture_output=True,
text=True,
check=True,
)
# Write the SQL to migration.sql
migration_file = migration_dir / "migration.sql"
migration_file.write_text(result.stdout)
print(f"Created baseline migration in {migration_dir}")
return True
except subprocess.CalledProcessError as e:
print(f"Error running prisma command: {e.stderr}")
return False
except Exception as e:
print(f"Error creating baseline migration: {str(e)}")
return False
if __name__ == "__main__":
create_baseline()

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