mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
Merge remote-tracking branch 'origin/main' into litellm_bedrock_batch_retrieve_endpoint
# Conflicts: # enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
This commit is contained in:
commit
5443bfac48
3334 changed files with 317607 additions and 83503 deletions
|
|
@ -1,54 +1,73 @@
|
|||
---
|
||||
name: database-access
|
||||
description: Add database queries, extract existing Prisma calls into repositories, or explicitly port queries to Rust and SQLx without expanding migration scope
|
||||
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 callers typed inputs and outputs, not Prisma clients, models, or query dictionaries
|
||||
- Keep query execution and backend selection inside the repository
|
||||
- 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
|
||||
|
||||
## Asked to move existing access behind a repository
|
||||
## Framework wiring
|
||||
|
||||
- Identify the calls being moved and their transaction boundary
|
||||
- Move the existing Prisma implementation into the relevant repository
|
||||
- Replace those direct database calls with repository operations
|
||||
- Preserve return values, errors, and transaction behavior, with focused regression tests
|
||||
- Keep this an extraction, not a backend migration or unrelated cleanup
|
||||
- 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
|
||||
|
||||
## An equivalent Rust or SQLx query already exists
|
||||
## Connection ownership
|
||||
|
||||
- Leave it and the current backend selection unchanged unless integration is explicitly requested
|
||||
- Its existence alone does not justify switching a Prisma caller to Rust
|
||||
- Repository extraction can keep using Prisma even when the Rust query is available
|
||||
- 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
|
||||
|
||||
## Asked to port a Prisma query to Rust or SQLx
|
||||
## Changing ownership or reloading resources
|
||||
|
||||
- Identify the existing inputs, outputs, errors, and transaction boundary
|
||||
- Check `litellm-rust/crates/db` for an equivalent query and reuse it if its behavior matches
|
||||
- Otherwise follow `litellm-rust/crates/db/AGENTS.md`
|
||||
- Put static SQL in `queries/` and compile-check it with SQLx
|
||||
- Keep typed inputs and reusable row projections in `src/`
|
||||
- Refresh SQLx query metadata after SQL changes
|
||||
- Keep Python conversion in the Python bridge, not in `litellm-rust/crates/db`
|
||||
- When caller integration is requested, expose the Rust operation through the bridge and call it from the repository
|
||||
- A query-only port can land without switching callers
|
||||
- Verify behavior against real PostgreSQL before switching callers
|
||||
- Check relevant null handling, conflicts, ordering, errors, and transaction semantics
|
||||
- Remove the old implementation only when no callers need it
|
||||
- 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 incrementally with Rust APIs
|
||||
- Mixed Prisma and Rust implementations are expected during the transition, unrelated existing calls can stay as they are
|
||||
- Both backends may have separate pools through PgBouncer, size them together and verify pooling-mode compatibility
|
||||
- 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
|
||||
|
|
|
|||
101
.agents/skills/database-access/references/python-fastapi.md
Normal file
101
.agents/skills/database-access/references/python-fastapi.md
Normal 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/)
|
||||
119
.agents/skills/database-access/references/python-repository.md
Normal file
119
.agents/skills/database-access/references/python-repository.md
Normal 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
|
||||
4
.agents/skills/database-access/references/rust-axum.md
Normal file
4
.agents/skills/database-access/references/rust-axum.md
Normal 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
7
.circleci/AGENTS.md
Normal 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
|
||||
|
|
@ -21,19 +21,30 @@ orbs:
|
|||
win: circleci/windows@5.0 # Add Windows orb
|
||||
|
||||
commands:
|
||||
checkout_migration_source:
|
||||
checkout_over_https:
|
||||
parameters:
|
||||
revision:
|
||||
type: string
|
||||
default: ""
|
||||
shell:
|
||||
type: string
|
||||
default: /bin/bash -eo pipefail
|
||||
steps:
|
||||
- run:
|
||||
name: Select the requested migration test revision
|
||||
name: Check out the public repository over HTTPS
|
||||
shell: << parameters.shell >>
|
||||
environment:
|
||||
MIGRATION_SOURCE_SHA: << pipeline.parameters.migration_source_sha >>
|
||||
REQUESTED_REVISION: << parameters.revision >>
|
||||
command: |
|
||||
revision="${MIGRATION_SOURCE_SHA:-$CIRCLE_SHA1}"
|
||||
revision="${REQUESTED_REVISION:-$CIRCLE_SHA1}"
|
||||
[[ "$revision" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
||||
git init
|
||||
git remote add origin https://github.com/BerriAI/litellm.git
|
||||
git fetch --depth 1 origin "$revision"
|
||||
git checkout --detach FETCH_HEAD
|
||||
for attempt in 1 2 3; do
|
||||
git fetch --filter=blob:none origin "$revision" && git checkout --detach FETCH_HEAD && break
|
||||
[[ $attempt -lt 3 ]] || exit 1
|
||||
sleep 5
|
||||
done
|
||||
skip_if_unrelated_changes:
|
||||
parameters:
|
||||
category:
|
||||
|
|
@ -347,7 +358,7 @@ commands:
|
|||
- when:
|
||||
condition: << parameters.checkout >>
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
|
|
@ -377,7 +388,8 @@ jobs:
|
|||
CARGO_HTTP_MULTIPLEXING: "false"
|
||||
CARGO_NET_RETRY: "5"
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https:
|
||||
shell: bash.exe
|
||||
- run:
|
||||
name: Skip job when no windows-release-relevant files changed
|
||||
shell: bash.exe
|
||||
|
|
@ -451,7 +463,7 @@ jobs:
|
|||
password: ${DOCKERHUB_PASSWORD}
|
||||
working_directory: ~/project
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- setup_google_dns
|
||||
- install_uv
|
||||
- install_rust
|
||||
|
|
@ -482,7 +494,7 @@ jobs:
|
|||
working_directory: ~/project
|
||||
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- skip_if_unrelated_changes
|
||||
- attach_workspace:
|
||||
at: ~/project
|
||||
|
|
@ -559,7 +571,7 @@ jobs:
|
|||
resource_class: medium
|
||||
working_directory: ~/project
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- run:
|
||||
name: Generate LiteLLM master key
|
||||
command: |
|
||||
|
|
@ -659,7 +671,7 @@ jobs:
|
|||
resource_class: large
|
||||
working_directory: ~/project
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- run:
|
||||
name: Generate LiteLLM master key
|
||||
command: |
|
||||
|
|
@ -740,7 +752,7 @@ jobs:
|
|||
resource_class: large
|
||||
working_directory: ~/project
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- skip_if_unrelated_changes:
|
||||
category: client
|
||||
- setup_google_dns
|
||||
|
|
@ -794,7 +806,7 @@ jobs:
|
|||
# the rest of the suite: nothing else performs a logout.
|
||||
PROXY_LOGOUT_URL: "https://www.example.com"
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- run:
|
||||
name: Generate LiteLLM master key
|
||||
command: |
|
||||
|
|
@ -940,7 +952,7 @@ jobs:
|
|||
# and by migration.serverRootPath.config.ts, which refuses to run without it.
|
||||
SERVER_ROOT_PATH: "/litellm"
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- run:
|
||||
name: Generate LiteLLM master key
|
||||
command: |
|
||||
|
|
@ -1066,11 +1078,12 @@ jobs:
|
|||
- when:
|
||||
condition: << parameters.migration_qualification >>
|
||||
steps:
|
||||
- checkout_migration_source
|
||||
- checkout_over_https:
|
||||
revision: << pipeline.parameters.migration_source_sha >>
|
||||
- unless:
|
||||
condition: << parameters.migration_qualification >>
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- skip_if_unrelated_changes
|
||||
|
||||
- run:
|
||||
|
|
@ -1122,7 +1135,8 @@ jobs:
|
|||
MIGRATION_TEST_OUTPUT: /tmp/migration-results
|
||||
PYTHONPATH: tests/e2e
|
||||
steps:
|
||||
- checkout_migration_source
|
||||
- checkout_over_https:
|
||||
revision: << pipeline.parameters.migration_source_sha >>
|
||||
- run:
|
||||
name: Generate LiteLLM master key
|
||||
command: |
|
||||
|
|
@ -1197,7 +1211,7 @@ jobs:
|
|||
resource_class: medium
|
||||
working_directory: ~/project
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- skip_if_unrelated_changes
|
||||
- attach_workspace:
|
||||
at: ~/project
|
||||
|
|
@ -1371,7 +1385,7 @@ jobs:
|
|||
environment:
|
||||
UV_PYTHON_DOWNLOADS: never
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- install_uv
|
||||
- run:
|
||||
name: Install the locked shard verifier and coverage dependencies
|
||||
|
|
@ -1419,7 +1433,7 @@ jobs:
|
|||
ROUTING_PARITY_BASE: << pipeline.parameters.routing_parity_base >>
|
||||
command: |
|
||||
[[ "$ROUTING_PARITY_BASE" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
||||
git fetch --depth 1 origin "$ROUTING_PARITY_BASE"
|
||||
git fetch --depth 1 --no-filter origin "$ROUTING_PARITY_BASE"
|
||||
git rm -r -f --quiet litellm enterprise litellm-proxy-extras
|
||||
git checkout "$ROUTING_PARITY_BASE" -- litellm enterprise litellm-proxy-extras
|
||||
git reset --quiet
|
||||
|
|
@ -1494,7 +1508,7 @@ jobs:
|
|||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/litellm_test
|
||||
steps:
|
||||
- checkout
|
||||
- checkout_over_https
|
||||
- skip_if_unrelated_changes
|
||||
- setup_litellm_test_deps:
|
||||
checkout: false
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${GITHUB_ACTIONS:-}" = true ]; then
|
||||
echo "Integration contracts are owned by CircleCI" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
suite="${1:?integration suite required}"
|
||||
mode="${2:-standard}"
|
||||
side="${3:-}"
|
||||
requested_workers="${INTEGRATION_WORKERS:-}"
|
||||
license_env=()
|
||||
if [ -n "${LITELLM_LICENSE:-}" ]; then
|
||||
license_env=("LITELLM_LICENSE=$LITELLM_LICENSE")
|
||||
fi
|
||||
if [ "$mode" = replica ]; then
|
||||
results="test-results/integration-${suite}-replica"
|
||||
elif [ "$mode" = parity ]; then
|
||||
|
|
@ -84,7 +84,7 @@ if [ "$suite" = browser ]; then
|
|||
export LITELLM_UI_PATH="$PWD/ui/litellm-dashboard/out"
|
||||
test -f "$LITELLM_UI_PATH/index.html"
|
||||
fi
|
||||
export INTEGRATION_SEED="$(.venv/bin/python -c 'import hashlib,os; print(int(hashlib.sha256((os.environ.get("CIRCLE_SHA1", "local") + os.environ.get("CIRCLE_WORKFLOW_ID", "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"
|
||||
|
||||
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma > "$results/prisma-generate.log" 2>&1
|
||||
|
|
@ -107,7 +107,7 @@ sudo iptables -N integration_only
|
|||
guard_created=true
|
||||
sudo iptables -A integration_only -o lo -j ACCEPT
|
||||
sudo iptables -A integration_only -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||
for service in postgres-db redis-cache; do
|
||||
for service in "${INTEGRATION_POSTGRES_CONTAINER:-postgres-db}" "${INTEGRATION_REDIS_CONTAINER:-redis-cache}"; do
|
||||
address="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$service")"
|
||||
sudo iptables -A integration_only -d "$address" -j ACCEPT
|
||||
done
|
||||
|
|
@ -128,6 +128,8 @@ fi
|
|||
sudo iptables -L integration_only -n -v -x > "$results/egress-guard.txt"
|
||||
awk '$3 == "REJECT" && $1 > 0 { rejected=1 } END { exit !rejected }' "$results/egress-guard.txt"
|
||||
|
||||
export UV_OFFLINE=true
|
||||
|
||||
if [ "$mode" = parity ]; then
|
||||
export INTEGRATION_WORKERS=1
|
||||
else
|
||||
|
|
@ -145,6 +147,9 @@ fi
|
|||
if [ "$suite" = mcp ]; then
|
||||
export INTEGRATION_WORKERS=2 INTEGRATION_COVERAGE=1
|
||||
fi
|
||||
if [ -n "$requested_workers" ]; then
|
||||
export INTEGRATION_WORKERS="$requested_workers"
|
||||
fi
|
||||
isolated_workers=false
|
||||
if [ "${INTEGRATION_WORKERS:-1}" -gt 1 ]; then
|
||||
isolated_workers=true
|
||||
|
|
@ -185,7 +190,7 @@ start_proxy() {
|
|||
"${database_env[@]}" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
|
||||
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \
|
||||
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" LITELLM_SALT_KEY="$LITELLM_SALT_KEY" LITELLM_UI_PATH="$LITELLM_UI_PATH" PROXY_BASE_URL="http://127.0.0.1:$port" \
|
||||
LITELLM_LICENSE="${LITELLM_LICENSE:-}" \
|
||||
"${license_env[@]}" \
|
||||
LITELLM_MODE=PRODUCTION STORE_MODEL_IN_DB=True LITELLM_ENABLE_MCP_STDIO=true "${cost_map_env[@]}" \
|
||||
AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 COVERAGE_FILE="$coverage_data" \
|
||||
"${proxy_command[@]}" --config tests/integration/proxy_config.yaml \
|
||||
|
|
@ -257,7 +262,7 @@ env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
|
|||
"${shared_services[@]}" "${worker_coverage[@]}" \
|
||||
INTEGRATION_WORKERS="${INTEGRATION_WORKERS:-1}" \
|
||||
LITELLM_MODE=PRODUCTION \
|
||||
LITELLM_LICENSE="${LITELLM_LICENSE:-}" \
|
||||
"${license_env[@]}" \
|
||||
INTEGRATION_SEED="$INTEGRATION_SEED" \
|
||||
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
|
||||
INTEGRATION_CASE_SHARDS="${CIRCLE_NODE_TOTAL:-1}" INTEGRATION_CASE_SHARD_INDEX="${CIRCLE_NODE_INDEX:-0}" \
|
||||
|
|
|
|||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
|
@ -1,5 +1,6 @@
|
|||
/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
|
||||
/ci_cd/cost_map_pins.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri
|
||||
/helm/ @yassin-berriai
|
||||
/terraform/ @yassin-berriai
|
||||
/.github/workflows/zizmor.yml @yuneng-berri
|
||||
|
|
|
|||
23
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
23
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
|
|
@ -4,9 +4,21 @@
|
|||
Drop every section you have nothing to put in, heading included: a bare "## Relevant issues" or
|
||||
"## Affected release" with nothing under it must not appear in the final description -->
|
||||
|
||||
<!-- Two or three plain sentences a teammate can read in 10 seconds, written the way you'd explain it in Slack
|
||||
## Handwritten 1-3 sentence description
|
||||
|
||||
<!-- The author writes a plain summary in their own words before requesting review.
|
||||
Wrap newly AI-written summary text in the make-me-human-readable tags below, including backticks.
|
||||
The author reviews or rewrites the draft and removes the tags.
|
||||
When updating an existing PR, preserve the author's wording and removed tags.
|
||||
Changes elsewhere in the body do not make an unchanged summary an AI draft again.
|
||||
Add tags again only around newly AI-written replacement summary text, never merely to match this template.
|
||||
This preserves author review across later updates (2026-11-02, #49779).
|
||||
Say what the PR does for users and why, then anything that keeps it working over time (a sync job, a config someone owns)
|
||||
No headings, bullets, tables, or code names here. Everything else goes inside the Details block below -->
|
||||
No bullets, tables, or code names here. Everything else goes inside the Details block below -->
|
||||
|
||||
`<make-me-human-readable>`
|
||||
<blah>
|
||||
`</make-me-human-readable>`
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
|
@ -84,9 +96,10 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac
|
|||
## Screenshots / Proof of Fix
|
||||
|
||||
<!-- Include screenshots, screen recordings, or command (e.g., curl) + output demonstrating that your changes work as expected
|
||||
The proof must be completely e2e with no mocks, using actual LLM calls costing real $$$ if applicable. `pytest` commands are not enough
|
||||
Show ONLY the latest run: capture Before at the merge base and After at the PR's current tip, and when new commits change behavior, replace this whole section with the fresh run instead of stacking it on top of older ones. The run must be up to date. As soon as a new commit is made and it makes this PR description's after sha stale (it's no longer tip of PR), you must re-run the QA
|
||||
Structure the section exactly as below: Before and After one heading level below this section, each naming the commit hash it was captured at, one lower-level heading per case inside each, the same case names in the same order on both sides, and numbered steps (command, observed output) under every case, never loose prose; shared setup (config, payloads) goes above Before, and with a single case, drop the case headings and number the steps directly
|
||||
Simple PR exception (2026-10-28, #49030). A mechanical change fully proven by focused checks may skip before/after e2e. Follow AGENTS.md's eligibility rule, trace callers, and verify bug-fix regression sensitivity. Record E2E exception at <tip SHA>, scope, rationale, commands and results here. Tests/static checks suffice only for this exception. Keep CI and bot gates, reassess changed diffs, and honor explicit e2e requests. Omit the Before/After scaffold for an exception
|
||||
Otherwise proof must be fully e2e, no mocks, with real provider calls where applicable. Pytest alone is insufficient. The following format applies to live proof
|
||||
Show only the latest proof. Before uses the merge base and After the tested tip. Rerun affected QA after behavior changes. Verified neutral diffs may carry proof with the tested SHA, current tip, equivalence reason and focused checks, following AGENTS.md and /qa
|
||||
Use matching Before/After case headings with numbered commands and observed output. Put shared setup above them. For one case, omit case headings
|
||||
|
||||
### Before (<hash>)
|
||||
|
||||
|
|
|
|||
119
.github/scripts/assert_secret_absent.py
vendored
Normal file
119
.github/scripts/assert_secret_absent.py
vendored
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import base64
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from functools import reduce
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final, TextIO
|
||||
|
||||
SECRET_VARIABLE: Final = "SCANNED_SECRET"
|
||||
MIN_ENCODED_LENGTH: Final = 12
|
||||
MAX_ARCHIVE_DEPTH: Final = 3
|
||||
MASK: Final = "***"
|
||||
|
||||
Forms = tuple[tuple[str, str], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Hit:
|
||||
location: str
|
||||
form: str
|
||||
|
||||
|
||||
def _aligned_encodings(raw: bytes, name: str, encode: Callable[[bytes], bytes]) -> Iterator[tuple[str, str]]:
|
||||
for offset in range(3):
|
||||
chunk: Final = raw[offset:]
|
||||
yield f"{name} offset {offset}", encode(chunk[: len(chunk) - len(chunk) % 3]).decode()
|
||||
|
||||
|
||||
def secret_forms(secret: str) -> Forms:
|
||||
encoded: Final = (
|
||||
("url-encoded", urllib.parse.quote(secret, safe="")),
|
||||
*_aligned_encodings(secret.encode(), "base64", base64.b64encode),
|
||||
*_aligned_encodings(secret.encode(), "base64url", base64.urlsafe_b64encode),
|
||||
)
|
||||
by_value: Final = MappingProxyType(
|
||||
{value: name for name, value in encoded if value != secret and len(value) >= MIN_ENCODED_LENGTH}
|
||||
)
|
||||
return (("raw", secret), *((name, value) for value, name in by_value.items()))
|
||||
|
||||
|
||||
def mask(text: str, forms: Forms) -> str:
|
||||
return reduce(lambda masked, form: masked.replace(form[1], MASK), forms, text)
|
||||
|
||||
|
||||
def _name_hits(location: str, name: str, forms: Forms) -> Iterator[Hit]:
|
||||
for form, value in forms:
|
||||
if value in name:
|
||||
yield Hit(mask(location, forms), f"{form} in name")
|
||||
|
||||
|
||||
def _content_hits(location: str, data: bytes, forms: Forms, depth: int) -> Iterator[Hit]:
|
||||
for form, value in forms:
|
||||
if value.encode() in data:
|
||||
yield Hit(mask(location, forms), form)
|
||||
if depth >= MAX_ARCHIVE_DEPTH or not zipfile.is_zipfile(io.BytesIO(data)):
|
||||
return
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
for member in archive.infolist():
|
||||
if member.is_dir():
|
||||
continue
|
||||
yield from _content_hits(f"{location}!{member.filename}", archive.read(member), forms, depth + 1)
|
||||
|
||||
|
||||
def _walk(root: Path) -> Iterator[tuple[Path, bool]]:
|
||||
for directory, subdirectories, files in os.walk(root):
|
||||
yield from ((Path(directory, name), True) for name in subdirectories if Path(directory, name).is_symlink())
|
||||
yield from ((Path(directory, name), False) for name in files)
|
||||
|
||||
|
||||
def _path_hits(root: Path, path: Path, linked_directory: bool, forms: Forms) -> Iterator[Hit]:
|
||||
location: Final = str(path.relative_to(root))
|
||||
if linked_directory:
|
||||
yield Hit(mask(location, forms), "directory symlink, not scanned")
|
||||
return
|
||||
yield from _name_hits(location, location, forms)
|
||||
yield from _content_hits(location, path.read_bytes(), forms, 0)
|
||||
|
||||
|
||||
def scan(root: Path, secret: str) -> tuple[int, tuple[Hit, ...]]:
|
||||
forms: Final = secret_forms(secret)
|
||||
entries: Final = tuple(_walk(root))
|
||||
hits: Final = tuple(chain.from_iterable(_path_hits(root, path, linked, forms) for path, linked in entries))
|
||||
return len(entries), hits
|
||||
|
||||
|
||||
def main(argv: tuple[str, ...], environ: Mapping[str, str], out: TextIO) -> int:
|
||||
if len(argv) != 1:
|
||||
print(f"usage: assert_secret_absent.py <directory>, with the secret in ${SECRET_VARIABLE}", file=out)
|
||||
return 2
|
||||
secret: Final = environ.get(SECRET_VARIABLE, "")
|
||||
root: Final = Path(argv[0])
|
||||
if not secret:
|
||||
print(f"{SECRET_VARIABLE} is empty, nothing to scan for", file=out)
|
||||
return 0
|
||||
if not root.is_dir():
|
||||
print(f"{root} is not a directory, nothing to scan", file=out)
|
||||
return 0
|
||||
try:
|
||||
scanned, hits = scan(root, secret)
|
||||
except Exception as error:
|
||||
print(f"scan failed with {type(error).__name__}, treating the results as unsafe", file=out)
|
||||
return 1
|
||||
for hit in hits:
|
||||
print(f"secret found: {hit.location} ({hit.form})", file=out)
|
||||
if hits:
|
||||
print(f"{len(hits)} copies of the secret under {root}, refusing to upload", file=out)
|
||||
return 1
|
||||
print(f"no copy of the secret in {scanned} files under {root}", file=out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(tuple(sys.argv[1:]), os.environ, sys.stdout))
|
||||
9
.github/scripts/smoke_test_native_wheel.py
vendored
9
.github/scripts/smoke_test_native_wheel.py
vendored
|
|
@ -7,6 +7,8 @@ import zipfile
|
|||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
COST_MAP: Final = Path(__file__).resolve().parents[2] / "model_prices_and_context_window.json"
|
||||
|
||||
CHILD_SCRIPT: Final = """
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
|
@ -34,6 +36,9 @@ else:
|
|||
after = module.gil_stats()
|
||||
if not isinstance(after.get("releases"), int):
|
||||
raise AssertionError(f"native module unusable after panic: {after!r}")
|
||||
|
||||
if module.bundled_model_cost_map() != Path(sys.argv[2]).read_bytes():
|
||||
raise AssertionError("the wheel embeds a different model_prices_and_context_window.json than this checkout")
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -55,7 +60,9 @@ def main() -> int:
|
|||
|
||||
native_path: Final = Path(temporary_directory) / Path(native_members[0].filename).name
|
||||
native_path.write_bytes(archive.read(native_members[0]))
|
||||
result: Final = subprocess.run((sys.executable, "-c", CHILD_SCRIPT, str(native_path)), check=False)
|
||||
result: Final = subprocess.run(
|
||||
(sys.executable, "-c", CHILD_SCRIPT, str(native_path), str(COST_MAP)), check=False
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(f"native wheel smoke test exited with status {result.returncode}\n")
|
||||
|
|
|
|||
4
.github/workflows/compat-matrix-image.yml
vendored
4
.github/workflows/compat-matrix-image.yml
vendored
|
|
@ -4,7 +4,7 @@ on:
|
|||
pull_request:
|
||||
paths:
|
||||
- tests/e2e/claude_code/cron_vm/**
|
||||
- tests/e2e/claude_code/pr_gate_version_resolver.py
|
||||
- tests/e2e/cli_harness/npm_version_resolver.py
|
||||
- .github/workflows/compat-matrix-image.yml
|
||||
workflow_dispatch:
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
whoami
|
||||
gh --version
|
||||
uv --version
|
||||
version="$(uv run --no-project --python 3.12 python /opt/litellm/tests/e2e/claude_code/pr_gate_version_resolver.py)"
|
||||
version="$(uv run --no-project --python 3.12 python /opt/litellm/tests/e2e/cli_harness/npm_version_resolver.py @anthropic-ai/claude-code)"
|
||||
/opt/litellm/tests/e2e/claude_code/cron_vm/install_claude_code.sh "${version}" /tmp/claude-cli
|
||||
/tmp/claude-cli/claude --version
|
||||
'
|
||||
|
|
|
|||
37
.github/workflows/cost-map-guard.yml
vendored
37
.github/workflows/cost-map-guard.yml
vendored
|
|
@ -8,6 +8,7 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
|
|
@ -21,16 +22,21 @@ jobs:
|
|||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- name: Fetch the pull request head and its merge base
|
||||
- name: Check out the base branch tip and fetch the pull request head and its merge base
|
||||
id: revisions
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
merge_base="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BASE_SHA}...${HEAD_SHA}" --jq '.merge_base_commit.sha')"
|
||||
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
|
||||
base_ref="$(gh api "repos/${GITHUB_REPOSITORY}/branches/${EVENT_BASE_REF}" --jq .name)" || base_ref="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq .base.ref)"
|
||||
target="$(gh api "repos/${GITHUB_REPOSITORY}/branches/${base_ref}" --jq '.commit.sha')"
|
||||
merge_base="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${target}...${HEAD_SHA}" --jq '.merge_base_commit.sha')"
|
||||
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA" "$target"
|
||||
git checkout -q --detach "$target"
|
||||
echo "merge_base=$merge_base" >> "$GITHUB_OUTPUT"
|
||||
echo "target=$target" >> "$GITHUB_OUTPUT"
|
||||
- name: Set up uv
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
|
|
@ -38,7 +44,30 @@ jobs:
|
|||
- name: Run the guard
|
||||
env:
|
||||
MERGE_BASE: ${{ steps.revisions.outputs.merge_base }}
|
||||
COST_MAP_GUARD_TARGET: ${{ steps.revisions.outputs.target }}
|
||||
COST_MAP_GUARD_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
uv run --no-project python ci_cd/cost_map_guard.py --base "$MERGE_BASE" --head "$HEAD_SHA" --head-ref "$HEAD_REF"
|
||||
- name: Check whether the pull request changes the cost map
|
||||
id: changed
|
||||
env:
|
||||
MERGE_BASE: ${{ steps.revisions.outputs.merge_base }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if git diff --quiet "$MERGE_BASE" "$HEAD_SHA" -- model_prices_and_context_window.json; then
|
||||
echo "cost_map=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "cost_map=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.changed.outputs.cost_map == 'true'
|
||||
run: rustup toolchain install --no-self-update
|
||||
- name: Load the cost map through model-catalog
|
||||
if: steps.changed.outputs.cost_map == 'true'
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
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 \
|
||||
-- "$RUNNER_TEMP/model_prices_and_context_window.json"
|
||||
|
|
|
|||
91
.github/workflows/cost-map-pins-recheck.yml
vendored
Normal file
91
.github/workflows/cost-map-pins-recheck.yml
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
name: Cost map pins recheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "litellm_**"
|
||||
paths:
|
||||
- model_prices_and_context_window.json
|
||||
- litellm/model_prices_and_context_window_backup.json
|
||||
- ci_cd/cost_map_pins.json
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
rerun-cost-map-guard:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Re-run the cost map guard on open pull requests the push could break
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_REF: ${{ github.ref_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
AFTER_SHA: ${{ github.event.after }}
|
||||
FORCED: ${{ github.event.forced }}
|
||||
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: '["ci_cd/cost_map_pins.json"]'
|
||||
run: |
|
||||
if [[ "$BEFORE_SHA" =~ ^0+$ || "$FORCED" == true ]] \
|
||||
|| ! gh api "repos/${GITHUB_REPOSITORY}/compare/${BEFORE_SHA}...${AFTER_SHA}?per_page=1" \
|
||||
--jq '(env.PINS_PATHS | fromjson) as $pins | (.files | length) < 300 and all(.files[]; [.filename, .previous_filename] | any(IN($pins[])) | not)' \
|
||||
| grep -qx true; then
|
||||
export RECHECK_PATHS="$COST_MAP_PATHS"
|
||||
else
|
||||
export RECHECK_PATHS="$PINS_PATHS"
|
||||
fi
|
||||
rerun_cutoff="$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
candidates="$(gh api graphql --paginate -F owner="${GITHUB_REPOSITORY_OWNER}" -F name="${GITHUB_REPOSITORY#*/}" -F base="$BASE_REF" \
|
||||
-f query='query($owner: String!, $name: String!, $base: String!, $endCursor: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequests(states: OPEN, baseRefName: $base, first: 25, after: $endCursor) {
|
||||
nodes { number headRefOid changedFiles files(first: 100) { nodes { path } } }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}' \
|
||||
--jq '(env.RECHECK_PATHS | fromjson) as $paths | .data.repository.pullRequests.nodes[]
|
||||
| {number, headRefOid, listed: any(.files.nodes[].path; IN($paths[])), unlisted: (.changedFiles > 100)}
|
||||
| select(.listed or .unlisted) | "\(.number) \(.headRefOid) \(.listed)"')"
|
||||
failed=0
|
||||
declare -A rerun_heads
|
||||
while read -r number head_sha listed; do
|
||||
[ -n "$number" ] || continue
|
||||
if [ "$listed" = false ]; then
|
||||
if ! changed="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${number}/files?per_page=100" \
|
||||
--jq '(env.RECHECK_PATHS | fromjson) as $paths | .[].filename | select(IN($paths[]))')"; then
|
||||
echo "::error::could not list the files of #$number"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
[ -n "$changed" ] || continue
|
||||
fi
|
||||
[ -z "${rerun_heads[$head_sha]:-}" ] || continue
|
||||
rerun_heads[$head_sha]=1
|
||||
if ! runs="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow cost-map-guard.yml --commit "$head_sha" --limit 100 --json databaseId,createdAt --jq '.[] | "\(.databaseId) \(.createdAt)"')"; then
|
||||
echo "::error::could not list the cost map guard runs of #$number"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
if [ -z "$runs" ]; then
|
||||
echo "::warning::no cost map guard run found for #$number"
|
||||
continue
|
||||
fi
|
||||
while read -r run_id created_at; do
|
||||
if [[ "$created_at" < "$rerun_cutoff" ]]; then
|
||||
echo "::warning::cost map guard run $run_id for #$number is over 30 days old, so GitHub cannot re-run it; #$number gets the pin checks on its next push"
|
||||
continue
|
||||
fi
|
||||
gh run watch "$run_id" --repo "$GITHUB_REPOSITORY" > /dev/null || true
|
||||
if ! gh run rerun "$run_id" --repo "$GITHUB_REPOSITORY"; then
|
||||
echo "::error::could not re-run cost map guard run $run_id for #$number"
|
||||
failed=1
|
||||
fi
|
||||
done <<< "$runs"
|
||||
done <<< "$candidates"
|
||||
exit "$failed"
|
||||
2
.github/workflows/create-rc-branch.yml
vendored
2
.github/workflows/create-rc-branch.yml
vendored
|
|
@ -2,7 +2,7 @@ name: Create RC Branch
|
|||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * 5"
|
||||
- cron: "0 3 * * 1"
|
||||
timezone: "America/Los_Angeles"
|
||||
workflow_dispatch:
|
||||
|
||||
|
|
|
|||
30
.github/workflows/deploy-dev-on-main.yml
vendored
30
.github/workflows/deploy-dev-on-main.yml
vendored
|
|
@ -1,30 +0,0 @@
|
|||
name: Deploy main to gateway-dev
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: deploy-dev-dispatch
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
if: github.repository == 'BerriAI/litellm'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
|
||||
id: token
|
||||
with:
|
||||
app-id: ${{ secrets.LITELLM_OPS_DISPATCH_APP_ID }}
|
||||
private-key: ${{ secrets.LITELLM_OPS_DISPATCH_APP_PRIVATE_KEY }}
|
||||
owner: BerriAI
|
||||
repositories: litellm-ops
|
||||
permission-actions: write
|
||||
- name: Dispatch the litellm-ops dev deploy
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
run: gh api --method POST repos/BerriAI/litellm-ops/actions/workflows/deploy-dev.yml/dispatches -f ref=main
|
||||
2
.github/workflows/image-scan.yml
vendored
2
.github/workflows/image-scan.yml
vendored
|
|
@ -13,8 +13,10 @@ on:
|
|||
- migrations/Dockerfile
|
||||
- migrations/run.py
|
||||
- gateway/Dockerfile
|
||||
- gateway/launch.py
|
||||
- gateway/main.py
|
||||
- gateway/routes/allowlist.py
|
||||
- litellm_cli/**
|
||||
- backend/Dockerfile
|
||||
- backend/main.py
|
||||
- litellm-rust/**
|
||||
|
|
|
|||
152
.github/workflows/lens-install-smoke.yml
vendored
152
.github/workflows/lens-install-smoke.yml
vendored
|
|
@ -1,152 +0,0 @@
|
|||
name: Lens installation smoke
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
lens_image:
|
||||
description: Verified baseline Lens image at ghcr.io/berriai/lens@sha256
|
||||
required: true
|
||||
type: string
|
||||
lens_upgrade_image:
|
||||
description: Compatible Lens upgrade image at ghcr.io/berriai/lens@sha256
|
||||
required: true
|
||||
type: string
|
||||
gateway_baseline_ref:
|
||||
description: Previously qualified gateway source commit, full SHA
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lens-install-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-images:
|
||||
runs-on: ubuntu-latest-16-cores
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- component: gateway
|
||||
dockerfile: gateway/Dockerfile
|
||||
- component: backend
|
||||
dockerfile: backend/Dockerfile
|
||||
- component: ui
|
||||
dockerfile: ui/Dockerfile
|
||||
- component: migrations
|
||||
dockerfile: migrations/Dockerfile
|
||||
- component: monolith
|
||||
dockerfile: Dockerfile
|
||||
- component: gateway
|
||||
dockerfile: gateway/Dockerfile
|
||||
baseline: true
|
||||
- component: backend
|
||||
dockerfile: backend/Dockerfile
|
||||
baseline: true
|
||||
- component: monolith
|
||||
dockerfile: Dockerfile
|
||||
baseline: true
|
||||
env:
|
||||
COMPONENT: ${{ matrix.component }}
|
||||
DOCKERFILE: ${{ matrix.dockerfile }}
|
||||
SOURCE_REF: ${{ matrix.baseline && inputs.gateway_baseline_ref || github.sha }}
|
||||
IMAGE_TAG: ${{ matrix.baseline && 'v0.0.0-lens-ci-baseline' || 'v0.0.0-lens-ci' }}
|
||||
CURRENT_REF: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
- name: Verify the selected source
|
||||
shell: bash -euo pipefail {0}
|
||||
run: |
|
||||
[[ "$SOURCE_REF" =~ ^[0-9a-f]{40}$ ]]
|
||||
test "$(git rev-parse HEAD)" = "$SOURCE_REF"
|
||||
if [[ "$IMAGE_TAG" == v0.0.0-lens-ci-baseline ]]; then
|
||||
test "$SOURCE_REF" != "$CURRENT_REF"
|
||||
fi
|
||||
- name: Build the matching release image
|
||||
run: |
|
||||
docker build --build-arg "LITELLM_RELEASE_TAG=$IMAGE_TAG" \
|
||||
-f "$DOCKERFILE" -t "lens-ci-$COMPONENT:$IMAGE_TAG" .
|
||||
- name: Save the matching release image
|
||||
run: |
|
||||
docker save "lens-ci-$COMPONENT:$IMAGE_TAG" \
|
||||
| gzip -1 > "$RUNNER_TEMP/lens-install-$COMPONENT-$IMAGE_TAG.tar.gz"
|
||||
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
with:
|
||||
name: lens-install-${{ matrix.component }}-${{ env.IMAGE_TAG }}-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/lens-install-${{ matrix.component }}-${{ env.IMAGE_TAG }}.tar.gz
|
||||
compression-level: 0
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
helm-install:
|
||||
needs: build-images
|
||||
runs-on: ubuntu-latest-16-cores
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
|
||||
with:
|
||||
pattern: lens-install-*-${{ github.sha }}
|
||||
merge-multiple: true
|
||||
path: ${{ runner.temp }}/lens-install-images
|
||||
- name: Load the matching release images
|
||||
run: |
|
||||
for archive in "$RUNNER_TEMP"/lens-install-images/lens-install-*.tar.gz; do
|
||||
gzip -dc "$archive" | docker load
|
||||
rm "$archive"
|
||||
done
|
||||
- uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
with:
|
||||
cosign-release: v2.4.3
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ vars.LENS_READ_USER || github.actor }}
|
||||
password: ${{ secrets.LENS_READ_TOKEN }}
|
||||
- name: Verify independent Lens images before loading them
|
||||
env:
|
||||
LENS_IMAGE: ${{ inputs.lens_image }}
|
||||
LENS_UPGRADE_IMAGE: ${{ inputs.lens_upgrade_image }}
|
||||
LENS_PUBLIC_KEY: ${{ vars.LENS_COSIGN_PUBLIC_KEY }}
|
||||
LENS_PUBLIC_KEY_SHA256: ${{ vars.LENS_COSIGN_PUBLIC_KEY_SHA256 }}
|
||||
shell: bash -euo pipefail {0}
|
||||
run: |
|
||||
[[ "$LENS_PUBLIC_KEY_SHA256" =~ ^[0-9a-f]{64}$ ]]
|
||||
printf '%s' "$LENS_PUBLIC_KEY" > "$RUNNER_TEMP/lens.pub"
|
||||
echo "$LENS_PUBLIC_KEY_SHA256 $RUNNER_TEMP/lens.pub" | sha256sum --check
|
||||
for image in "$LENS_IMAGE" "$LENS_UPGRADE_IMAGE"; do
|
||||
[[ "$image" =~ ^ghcr.io/berriai/lens@sha256:[0-9a-f]{64}$ ]]
|
||||
cosign verify --key "$RUNNER_TEMP/lens.pub" "$image" > /dev/null
|
||||
cosign verify-attestation --key "$RUNNER_TEMP/lens.pub" --type spdxjson "$image" > /dev/null
|
||||
docker pull "$image"
|
||||
done
|
||||
docker tag "$LENS_IMAGE" lens-ci-worker:baseline
|
||||
docker tag "$LENS_UPGRADE_IMAGE" lens-ci-worker:upgrade
|
||||
- name: Install pinned Kubernetes test tools
|
||||
run: |
|
||||
curl --fail --location --output "$RUNNER_TEMP/kind" \
|
||||
https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64
|
||||
echo "a6875aaea358acf0ac07786b1a6755d08fd640f4c79b7a2e46681cc13f49a04b $RUNNER_TEMP/kind" | sha256sum --check
|
||||
chmod +x "$RUNNER_TEMP/kind"
|
||||
curl --fail --location --output "$RUNNER_TEMP/kubectl" \
|
||||
https://dl.k8s.io/release/v1.32.2/bin/linux/amd64/kubectl
|
||||
echo "4f6a959dcc5b702135f8354cc7109b542a2933c46b808b248a214c1f69f817ea $RUNNER_TEMP/kubectl" | sha256sum --check
|
||||
chmod +x "$RUNNER_TEMP/kubectl"
|
||||
curl --fail --location --output "$RUNNER_TEMP/helm.tar.gz" \
|
||||
https://get.helm.sh/helm-v3.19.0-linux-amd64.tar.gz
|
||||
echo "a7f81ce08007091b86d8bd696eb4d86b8d0f2e1b9f6c714be62f82f96a594496 $RUNNER_TEMP/helm.tar.gz" | sha256sum --check
|
||||
tar -xzf "$RUNNER_TEMP/helm.tar.gz" -C "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
echo "$RUNNER_TEMP/linux-amd64" >> "$GITHUB_PATH"
|
||||
- name: Install, ingest, upgrade, and restart both charts
|
||||
run: bash tests/e2e/migrations/lens_helm_smoke.sh
|
||||
3
.github/workflows/osv-scan.yml
vendored
3
.github/workflows/osv-scan.yml
vendored
|
|
@ -5,6 +5,9 @@ on:
|
|||
branches:
|
||||
- main
|
||||
- "litellm_**"
|
||||
- "rc/**"
|
||||
- "stable/**"
|
||||
- "lts/**"
|
||||
schedule:
|
||||
- cron: "23 6 * * *"
|
||||
workflow_dispatch:
|
||||
|
|
|
|||
6
.github/workflows/spend-logs-guard.yml
vendored
6
.github/workflows/spend-logs-guard.yml
vendored
|
|
@ -19,17 +19,17 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
ref: ${{ github.event.pull_request.base.ref }}
|
||||
persist-credentials: false
|
||||
- name: Fetch the pull request head, its merge base and its reviews
|
||||
id: revisions
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
merge_base="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BASE_SHA}...${HEAD_SHA}" --jq '.merge_base_commit.sha')"
|
||||
merge_base="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BASE_REF}...${HEAD_SHA}" --jq '.merge_base_commit.sha')"
|
||||
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
|
||||
gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate --slurp | jq 'add // []' > "$RUNNER_TEMP/reviews.json"
|
||||
echo "merge_base=$merge_base" >> "$GITHUB_OUTPUT"
|
||||
|
|
|
|||
199
.github/workflows/test-integration.yml
vendored
Normal file
199
.github/workflows/test-integration.yml
vendored
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
name: integration
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
schedule:
|
||||
- cron: "5 8-20/3 * * 1-6"
|
||||
timezone: America/Los_Angeles
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suite:
|
||||
description: Integration group to run
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- providers
|
||||
- extensions
|
||||
- accounting
|
||||
- management
|
||||
- management-replica
|
||||
- database
|
||||
- database-replica
|
||||
- mcp
|
||||
- security
|
||||
- cost
|
||||
- sdk
|
||||
- browser
|
||||
default: all
|
||||
workers:
|
||||
description: Workers per group, blank keeps each group's default
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event.label.name }}-${{ inputs.suite }}-${{ inputs.workers }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
name: integration ${{ matrix.suite }}
|
||||
if: github.event_name != 'pull_request' || github.event.label.name == 'run-ci'
|
||||
environment: integration-license
|
||||
runs-on: ${{ fromJSON('{"providers":"ubuntu-latest-64-cores","extensions":"ubuntu-latest-64-cores","accounting":"ubuntu-latest-32-cores","management":"ubuntu-latest-32-cores","management-replica":"ubuntu-latest-32-cores"}')[matrix.suite] || 'ubuntu-latest-16-cores' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite: ${{ fromJSON((inputs.suite || 'all') == 'all' && '["providers","extensions","accounting","management","management-replica","database","database-replica","mcp","security","cost","sdk","browser"]' || format('["{0}"]', inputs.suite)) }}
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
INTEGRATION_WORKERS: ${{ inputs.workers || fromJSON('{"providers":"32","extensions":"48","accounting":"16","management":"16","management-replica":"16","cost":"16","mcp":"8","security":"8","database":"4","database-replica":"4"}')[matrix.suite] || '' }}
|
||||
services:
|
||||
postgres:
|
||||
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:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: circle_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--shm-size 1g
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7-alpine@sha256:7aec734b2bb298a1d769fd8729f13b8514a41bf90fcdd1f38ec52267fbaa8ee6
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
timeout-minutes: 3
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
|
||||
- name: Set up uv
|
||||
timeout-minutes: 3
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache the Rust build
|
||||
timeout-minutes: 5
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Cache Prisma binaries
|
||||
timeout-minutes: 5
|
||||
uses: ./.github/actions/cache-prisma-binaries
|
||||
|
||||
- name: Install dependencies
|
||||
timeout-minutes: 20
|
||||
run: .github/scripts/uv_sync_with_retries.sh --frozen --all-groups --all-extras
|
||||
|
||||
- name: Install redis-server 7.2.16
|
||||
timeout-minutes: 3
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/redis-bin"
|
||||
cid="$(docker create redis:7.2.16@sha256:0637954999d01b7c9ce9167db2da50656e2590d3b884f1c600c5f63bb6e6773c)"
|
||||
docker cp "${cid}:/usr/local/bin/redis-server" "$RUNNER_TEMP/redis-bin/redis-server"
|
||||
docker rm "$cid"
|
||||
"$RUNNER_TEMP/redis-bin/redis-server" --version | grep -q 'v=7.2.16'
|
||||
echo "$RUNNER_TEMP/redis-bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install MCP SDK1 peer
|
||||
if: matrix.suite == 'mcp'
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
uv venv --python 3.12 .venv-mcp-peer
|
||||
uv pip install --no-config --exclude-newer "3 days" --python .venv-mcp-peer 'mcp==1.28.1' 'langchain-mcp-adapters==0.2.1'
|
||||
echo "MCP_TEST_PEER_PYTHON=$PWD/.venv-mcp-peer/bin/python" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Node.js
|
||||
if: matrix.suite == 'browser'
|
||||
timeout-minutes: 5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version-file: ui/litellm-dashboard/.nvmrc
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
ui/litellm-dashboard/package-lock.json
|
||||
tests/e2e/ui/package-lock.json
|
||||
|
||||
- name: Install browser dependencies and build the dashboard
|
||||
if: matrix.suite == 'browser'
|
||||
timeout-minutes: 20
|
||||
run: |
|
||||
(cd ui/litellm-dashboard && npm ci)
|
||||
(cd tests/e2e/ui && npm ci)
|
||||
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 chromium
|
||||
(cd ui/litellm-dashboard && NEXT_TELEMETRY_DISABLED=1 npm run build)
|
||||
|
||||
- name: Run integration contracts
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
SUITE: ${{ matrix.suite }}
|
||||
INTEGRATION_POSTGRES_CONTAINER: ${{ job.services.postgres.id }}
|
||||
INTEGRATION_REDIS_CONTAINER: ${{ job.services.redis.id }}
|
||||
LITELLM_LICENSE: ${{ secrets.LITELLM_LICENSE }}
|
||||
run: |
|
||||
case "$SUITE" in
|
||||
*-replica) bash .circleci/scripts/run_integration.sh "${SUITE%-replica}" replica ;;
|
||||
*) bash .circleci/scripts/run_integration.sh "$SUITE" ;;
|
||||
esac
|
||||
|
||||
- name: Verify every case ran exactly once
|
||||
if: ${{ !cancelled() && matrix.suite != 'browser' }}
|
||||
timeout-minutes: 2
|
||||
run: |
|
||||
record="$(find test-results -mindepth 2 -maxdepth 2 -name execution.json -print -quit)"
|
||||
test -n "$record"
|
||||
mkdir -p "$RUNNER_TEMP/shards/0"
|
||||
cp "$record" "$RUNNER_TEMP/shards/0/execution.json"
|
||||
.venv/bin/python -m tests.integration._support.sharding "$RUNNER_TEMP/shards" --shards 1
|
||||
|
||||
- name: Report MCP coverage
|
||||
if: ${{ !cancelled() && matrix.suite == 'mcp' }}
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
coverage_dir=test-results/integration-mcp/coverage
|
||||
test -f "$coverage_dir/coverage.txt" && exit 0
|
||||
export COVERAGE_FILE="$PWD/$coverage_dir/data"
|
||||
.venv/bin/python -m coverage combine --rcfile=tests/integration/mcp_coverage.toml
|
||||
.venv/bin/python -m coverage report --rcfile=tests/integration/mcp_coverage.toml > "$coverage_dir/coverage.txt"
|
||||
.venv/bin/python -m coverage html --rcfile=tests/integration/mcp_coverage.toml -d "$coverage_dir/html"
|
||||
tail -n 1 "$coverage_dir/coverage.txt"
|
||||
|
||||
- name: Check results for the license
|
||||
id: secret-scan
|
||||
if: ${{ !cancelled() }}
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
SCANNED_SECRET: ${{ secrets.LITELLM_LICENSE }}
|
||||
run: python .github/scripts/assert_secret_absent.py test-results
|
||||
|
||||
- name: Upload results
|
||||
if: ${{ !cancelled() && steps.secret-scan.outcome == 'success' }}
|
||||
timeout-minutes: 10
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
with:
|
||||
name: integration-${{ matrix.suite }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: test-results/
|
||||
retention-days: 14
|
||||
31
.github/workflows/test-linting.yml
vendored
31
.github/workflows/test-linting.yml
vendored
|
|
@ -48,11 +48,13 @@ jobs:
|
|||
if: steps.changes.outputs.decision != 'skip'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
retry() { "$@" || { sleep 15; "$@"; } || { sleep 30; "$@"; }; }
|
||||
MERGE_BASE=$(retry gh api "repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
|
||||
BASE_REF=$(gh api "repos/${GITHUB_REPOSITORY}/branches/${EVENT_BASE_REF}" --jq .name) || BASE_REF=$(retry gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq .base.ref)
|
||||
MERGE_BASE=$(retry gh api "repos/${GITHUB_REPOSITORY}/compare/${BASE_REF}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
|
||||
test -n "$MERGE_BASE"
|
||||
retry git fetch --no-tags --depth=1 origin "$MERGE_BASE"
|
||||
echo "GATE_BASE_SHA=$MERGE_BASE" >> "$GITHUB_ENV"
|
||||
|
|
@ -206,7 +208,7 @@ jobs:
|
|||
exit 0
|
||||
fi
|
||||
retry() { "$@" || { sleep 15; "$@"; } || { sleep 30; "$@"; }; }
|
||||
CLAUDE_VERSION="$(retry uv run --no-sync python tests/e2e/claude_code/pr_gate_version_resolver.py)"
|
||||
CLAUDE_VERSION="$(retry uv run --no-sync python tests/e2e/cli_harness/npm_version_resolver.py @anthropic-ai/claude-code)"
|
||||
tests/e2e/claude_code/cron_vm/install_claude_code.sh "$CLAUDE_VERSION" "$RUNNER_TEMP/claude-cli"
|
||||
PATH="$RUNNER_TEMP/claude-cli:$PATH" uv run --no-sync pytest -q tests/e2e_harness
|
||||
uv run --no-sync pytest --tb=short -q --noconftest -o addopts= -o "pythonpath=tests/e2e tests/e2e_harness" -p no:rerunfailures \
|
||||
|
|
@ -264,6 +266,7 @@ jobs:
|
|||
name: ui
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
defaults:
|
||||
|
|
@ -281,16 +284,12 @@ jobs:
|
|||
id: changed
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
# base.sha is the base branch tip from when the PR was opened, while
|
||||
# actions/checkout leaves HEAD on a merge of the PR into the *current*
|
||||
# base tip. "$BASE_SHA"...HEAD therefore spans every base-branch commit
|
||||
# landed since, so a PR that touches no UI file still gets linted
|
||||
# against hundreds of other people's files. Diff the PR head against its
|
||||
# own merge base instead, which is exactly what this PR changed.
|
||||
merge_base=$(gh api "repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
|
||||
base_ref=$(gh api "repos/${GITHUB_REPOSITORY}/branches/${EVENT_BASE_REF}" --jq .name) || base_ref=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq .base.ref)
|
||||
merge_base=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${base_ref}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
|
||||
test -n "$merge_base"
|
||||
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
|
||||
: > "$RUNNER_TEMP/prettier_files.txt"
|
||||
|
|
@ -376,10 +375,10 @@ jobs:
|
|||
exit 0
|
||||
fi
|
||||
files="$(git diff --name-only "$base" HEAD)"
|
||||
if grep -Eq '^(litellm/(proxy|types)/|ui/litellm-dashboard/(src/lib/http/schema\.d\.ts|scripts/gen-api-types\.mjs|package(-lock)?\.json)$|\.github/workflows/test-linting\.yml$)' <<< "$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"
|
||||
else
|
||||
echo "No proxy, types 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."
|
||||
echo "relevant=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
|
|
@ -630,6 +629,12 @@ jobs:
|
|||
- name: check_migrations_lock_timeout
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/check_migrations_lock_timeout.py
|
||||
|
||||
- name: check_no_none_string_error_fields
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/check_no_none_string_error_fields.py
|
||||
|
||||
- name: test_check_no_none_string_error_fields
|
||||
run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_check_no_none_string_error_fields.py
|
||||
|
||||
- name: check_no_publicly_known_master_key
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/check_no_publicly_known_master_key.py
|
||||
|
||||
|
|
|
|||
10
.github/workflows/test-rust-db.yml
vendored
10
.github/workflows/test-rust-db.yml
vendored
|
|
@ -42,6 +42,11 @@ concurrency:
|
|||
|
||||
env:
|
||||
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"
|
||||
|
||||
jobs:
|
||||
db-queries:
|
||||
|
|
@ -87,6 +92,11 @@ jobs:
|
|||
workspaces: litellm-rust
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Set up sccache
|
||||
uses: mozilla-actions/sccache-action@d651010b8da762cde178750d8eda7b5febfe147a
|
||||
with:
|
||||
version: "v0.18.0"
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: .
|
||||
run: uv sync --frozen --extra extra-proxy
|
||||
|
|
|
|||
20
.github/workflows/test-rust.yml
vendored
20
.github/workflows/test-rust.yml
vendored
|
|
@ -78,6 +78,11 @@ concurrency:
|
|||
|
||||
env:
|
||||
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"
|
||||
|
||||
jobs:
|
||||
rust-lint:
|
||||
|
|
@ -101,6 +106,11 @@ jobs:
|
|||
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"
|
||||
|
||||
- run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
rust-test:
|
||||
|
|
@ -143,6 +153,11 @@ jobs:
|
|||
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: Check generated trace contracts
|
||||
working-directory: .
|
||||
run: uv run scripts/generate_trace_types.py --check
|
||||
|
|
@ -189,6 +204,11 @@ jobs:
|
|||
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"
|
||||
|
||||
- run: uv build --wheel --out-dir dist
|
||||
|
||||
- run: python .github/scripts/verify_linux_native_wheel.py dist/*.whl
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ on:
|
|||
paths:
|
||||
- "terraform/provider/**"
|
||||
- "litellm/proxy/**"
|
||||
- "enterprise/**"
|
||||
- ".github/workflows/test-terraform-provider.yml"
|
||||
|
||||
permissions:
|
||||
|
|
|
|||
66
.github/workflows/test-unit.yml
vendored
66
.github/workflows/test-unit.yml
vendored
|
|
@ -5,6 +5,9 @@ on:
|
|||
branches:
|
||||
- main
|
||||
- "litellm_**"
|
||||
- "rc/**"
|
||||
- "stable/**"
|
||||
- "lts/**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
|
@ -73,9 +76,21 @@ jobs:
|
|||
timeout-minutes: 5
|
||||
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
|
||||
if: steps.changes.outputs.decision != 'skip' && steps.rust-bridge-cache.outputs.cache-hit != 'true'
|
||||
timeout-minutes: 15
|
||||
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"
|
||||
run: |
|
||||
mkdir -p rust-bridge-dist
|
||||
uv run --no-project --with maturin==1.15.0 python -c 'import maturin; maturin.build_editable("rust-bridge-dist")'
|
||||
|
|
@ -227,6 +242,7 @@ jobs:
|
|||
tests/unit/llms/test_oss_decision.py
|
||||
tests/unit/llms/test_polling_url_origin_match.py
|
||||
tests/unit/llms/test_predibase_transformation.py
|
||||
tests/unit/llms/test_subscription_accounts.py
|
||||
tests/unit/llms/a2a
|
||||
tests/unit/llms/aiml
|
||||
tests/unit/llms/aiohttp_openai
|
||||
|
|
@ -236,6 +252,7 @@ jobs:
|
|||
tests/unit/llms/azure_ai
|
||||
tests/unit/llms/base_llm
|
||||
tests/unit/llms/baseten
|
||||
tests/unit/llms/bigmodel
|
||||
tests/unit/llms/black_forest_labs
|
||||
tests/unit/llms/brave
|
||||
tests/unit/llms/bytez
|
||||
|
|
@ -244,6 +261,7 @@ jobs:
|
|||
tests/unit/llms/chatgpt
|
||||
tests/unit/llms/clarifai
|
||||
tests/unit/llms/claude_code
|
||||
tests/unit/llms/clf_ai_gateway
|
||||
tests/unit/llms/cloudflare
|
||||
tests/unit/llms/codestral
|
||||
tests/unit/llms/codex
|
||||
|
|
@ -265,6 +283,7 @@ jobs:
|
|||
tests/unit/llms/e2b
|
||||
tests/unit/llms/edenai
|
||||
tests/unit/llms/elevenlabs
|
||||
tests/unit/llms/empiriolabs
|
||||
tests/unit/llms/exa_ai
|
||||
tests/unit/llms/fal_ai
|
||||
tests/unit/llms/fastcrw
|
||||
|
|
@ -321,6 +340,7 @@ jobs:
|
|||
tests/unit/llms/openai_like
|
||||
tests/unit/llms/opencode
|
||||
tests/unit/llms/opensandbox
|
||||
tests/unit/llms/opensearch
|
||||
tests/unit/llms/openrouter
|
||||
tests/unit/llms/ovhcloud
|
||||
tests/unit/llms/parallel_ai
|
||||
|
|
@ -425,6 +445,7 @@ jobs:
|
|||
tests/unit/ocr
|
||||
tests/unit/passthrough
|
||||
tests/unit/rag
|
||||
tests/unit/cookbook
|
||||
tests/unit/realtime_api
|
||||
tests/unit/rerank_api
|
||||
tests/unit/search
|
||||
|
|
@ -561,6 +582,7 @@ jobs:
|
|||
tests/unit/proxy/rerank_endpoints
|
||||
tests/unit/proxy/realtime_endpoints
|
||||
tests/unit/proxy/ui_crud_endpoints
|
||||
tests/unit/proxy/deps
|
||||
tests/unit/proxy/config_resolvers
|
||||
tests/unit/proxy/utils
|
||||
python-version: "3.12"
|
||||
|
|
@ -955,10 +977,10 @@ jobs:
|
|||
dist: loadscope
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
timeout-minutes: ${{ matrix.shard == 'db-and-spend' && 10 || 3 }}
|
||||
timeout-minutes: 3
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: ${{ matrix.shard == 'db-and-spend' && '0' || '1' }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Detect relevant changes
|
||||
id: changes
|
||||
|
|
@ -991,27 +1013,37 @@ jobs:
|
|||
with:
|
||||
artifact: ${{ needs.rust-bridge.outputs.artifact }}
|
||||
|
||||
- name: Install dependencies and check schema
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
RUST_BRIDGE_ARTIFACT: ${{ needs.rust-bridge.outputs.artifact }}
|
||||
UNIT_SHARD: ${{ matrix.shard }}
|
||||
SCHEMA_BASE_REF: origin/${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
diff -u model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json
|
||||
if [ -z "$RUST_BRIDGE_ARTIFACT" ]; then
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra caching --extra extra_proxy --extra proxy-runtime --extra utils
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra extra_proxy --extra proxy-runtime --extra utils
|
||||
else
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra caching --extra extra_proxy --extra proxy-runtime --extra utils --no-install-project
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra extra_proxy --extra proxy-runtime --extra utils --no-install-project
|
||||
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
|
||||
uv run --no-sync python -c "import importlib.metadata; import litellm.rust_bridge._native; print(importlib.metadata.version('litellm'))"
|
||||
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"]'
|
||||
if [ "$UNIT_SHARD" = "db-and-spend" ]; then
|
||||
uv run --no-sync python scripts/check_schema_compatibility.py --base-ref "$SCHEMA_BASE_REF"
|
||||
fi
|
||||
|
||||
- name: Check schema.prisma against the merge base
|
||||
if: steps.changes.outputs.decision != 'skip' && matrix.shard == 'db-and-spend' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 3
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
base_ref=$(gh api "repos/${GITHUB_REPOSITORY}/branches/${EVENT_BASE_REF}" --jq .name) || base_ref=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq .base.ref)
|
||||
merge_base=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${base_ref}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
|
||||
test -n "$merge_base"
|
||||
git fetch --no-tags --depth=1 origin "$merge_base"
|
||||
uv run --no-sync python scripts/check_schema_compatibility.py --base-commit "$merge_base"
|
||||
|
||||
- name: Cache Prisma binaries
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
|
|
@ -1148,18 +1180,20 @@ jobs:
|
|||
env:
|
||||
CI: "true"
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
full_suite() { npm run test -- --run --pool forks --maxWorkers=14; }
|
||||
|
||||
if [ -z "$BASE_SHA" ]; then
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "Push to $GITHUB_REF_NAME: running the full suite"
|
||||
full_suite
|
||||
exit 0
|
||||
fi
|
||||
|
||||
merge_base=$(gh api "repos/${{ github.repository }}/compare/${BASE_SHA}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
|
||||
base_ref=$(gh api "repos/${GITHUB_REPOSITORY}/branches/${EVENT_BASE_REF}" --jq .name) || base_ref=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq .base.ref)
|
||||
merge_base=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${base_ref}...${HEAD_SHA}?per_page=1" --jq '.merge_base_commit.sha')
|
||||
test -n "$merge_base"
|
||||
git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA"
|
||||
changed_files=()
|
||||
|
|
@ -1449,9 +1483,9 @@ jobs:
|
|||
RUST_BRIDGE_ARTIFACT: ${{ needs.rust-bridge.outputs.artifact }}
|
||||
run: |
|
||||
if [ -z "$RUST_BRIDGE_ARTIFACT" ]; then
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra caching --extra extra_proxy --extra proxy-runtime --extra utils
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra extra_proxy --extra proxy-runtime --extra utils
|
||||
else
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra caching --extra extra_proxy --extra proxy-runtime --extra utils --no-install-project
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra extra_proxy --extra proxy-runtime --extra utils --no-install-project
|
||||
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
|
||||
fi
|
||||
|
|
|
|||
85
.github/workflows/weekly_load_anomaly.yml
vendored
85
.github/workflows/weekly_load_anomaly.yml
vendored
|
|
@ -1,85 +0,0 @@
|
|||
name: "Weekly Load Anomaly Check"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * 6"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
weekly-load-anomaly:
|
||||
if: github.event_name != 'schedule' || github.repository == 'BerriAI/litellm'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16.6
|
||||
env:
|
||||
POSTGRES_USER: llmproxy
|
||||
POSTGRES_PASSWORD: dbpassword9090
|
||||
POSTGRES_DB: litellm
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U llmproxy"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
DATABASE_URL: postgresql://llmproxy:dbpassword9090@localhost:5432/litellm
|
||||
LITELLM_MASTER_KEY: sk-weekly-anomaly-check
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache the Rust build
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra proxy
|
||||
|
||||
- name: Cache Prisma binaries
|
||||
uses: ./.github/actions/cache-prisma-binaries
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: |
|
||||
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
|
||||
|
||||
- name: Start the proxy
|
||||
run: |
|
||||
nohup uv run --no-sync litellm --config tests/e2e/perf/weekly_anomaly_config.yml --port 4000 > proxy.log 2>&1 &
|
||||
for _ in $(seq 1 90); do
|
||||
if curl -fs http://localhost:4000/health/liveliness > /dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "proxy never became live"
|
||||
tail -n 100 proxy.log
|
||||
exit 1
|
||||
|
||||
- name: Run the weekly session anomaly test
|
||||
env:
|
||||
E2E_WEEKLY_ANOMALY: "1"
|
||||
run: |
|
||||
uv run --no-sync pytest tests/e2e/perf/test_weekly_session_anomaly_e2e.py -v --tb=short -rA
|
||||
|
||||
- name: Show proxy log on failure
|
||||
if: failure()
|
||||
run: tail -n 300 proxy.log
|
||||
|
|
@ -11,3 +11,12 @@ ignore:
|
|||
package:
|
||||
name: zlib
|
||||
type: apk
|
||||
- vulnerability: GHSA-v4x8-3wr5-3wq8
|
||||
reason: >-
|
||||
filelock ReadWriteLock advisory, and no package in the image takes ReadWriteLock;
|
||||
the fix (4.2.1) is blocked by litellm's own filelock<4.0 cap until #48635 or #48802 lifts it,
|
||||
and the image then moves off 3.32.7, which retires this version-scoped rule
|
||||
package:
|
||||
name: filelock
|
||||
version: 3.32.7
|
||||
type: python
|
||||
|
|
|
|||
11
AGENTS.md
11
AGENTS.md
|
|
@ -31,6 +31,8 @@ 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
|
||||
|
||||
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
|
||||
|
|
@ -39,7 +41,11 @@ Same applies for filing bug reports and feature requests, with .github/ISSUE_TEM
|
|||
|
||||
If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just drop the section
|
||||
|
||||
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`. The Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into. I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, or the main use case runs through a headful agentic coding tool like Claude Code or Codex, drive that surface yourself and embed your own before and after screenshots of it in the PR (the Admin UI page, or what the coding tool shows), next to an ordered list of the URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, and what fields to fill out so a reviewer can reproduce it
|
||||
Run QA proxies with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --num_workers 2 --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`, and swap its `--config` for a copy of `litellm/proxy/dev_config.yaml` that points both `router_settings` (`redis_host`, `redis_port`) and `general_settings.coordination_redis` (`host`, `port`) at one dedicated Redis instance, with credentials when required, so the workers share router state and spend, budget, and rate-limit counters. Check that two worker PIDs are serving, and use the same worker count and Redis setup for the before and after proof. When the PR is about `--run_gunicorn`, `--run_hypercorn`, or `--run_granian`, which `--reload` does not support, drop `--reload`. When it is about behavior without Redis, drop Redis. When the proxy prints `"workers" flag is ignored when reloading is enabled`, that checkout or its uvicorn reloads only one worker, so drop the two-worker check for it. Say in the proof which part you dropped and why
|
||||
|
||||
Simple PRs may skip before/after e2e when the full diff is mechanical and focused checks completely prove it, such as #49030 restoring an existing helper name. Trace affected callers and run relevant tests/static checks at the tip. For a bug fix, establish that the regression catches the original defect. Size alone never qualifies. New behavior, API/provider semantics, auth, billing, persistence, concurrency or uncertain integration risk need live e2e. In Screenshots / Proof of Fix, record `E2E exception at <tip SHA>`, scope, rationale, commands and results. Tests count as proof only for this exception. Reassess changed diffs, retain CI/bot gates, and honor explicit e2e requests (2026-10-28, unnecessary live QA for #49030)
|
||||
|
||||
For other PRs, proof must show the end user's real flow before and after, with commands and output against a live proxy and real provider calls where applicable. Pytest alone is insufficient. For UI or interactive coding-tool flows, drive that surface and embed before/after screenshots, with URLs, clicks and input fields a reviewer can reproduce
|
||||
|
||||
If you ever write any human-facing text (pull requests, issues, commit messages, discussion posts, github comments, release notes, docs, etc.), always follow these guidelines to sound less AI-y:
|
||||
- don't use emojis
|
||||
|
|
@ -86,6 +92,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
|
||||
|
||||
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>`
|
||||
|
||||
Follow these coding conventions for new/updated code (a three-line fix in a legacy file shouldn't trigger huge drive-by refactors):
|
||||
|
|
@ -105,6 +113,7 @@ Follow these coding conventions for new/updated code (a three-line fix in a lega
|
|||
- No file sprawl: deliberate file and folder structure
|
||||
- Standard over hand-rolled: use the official SDK or a library where one exists. Where none does, follow industry standards instead of inventing local conventions
|
||||
- API-fragmentation-aware: when logic must branch on which API surface produced or consumes data (e.g. chat completions vs Anthropic Messages vs Responses API shapes), proactively look for an existing shared helper (e.g. `litellm_core_utils/prompt_templates/factory.py`) before writing per-surface parsing in the new module. If none exists, add one there instead of duplicating the same format-detection logic in every new guardrail/integration
|
||||
- Current default for new model names: logic that picks behavior from a model-name table (tokenizer, message framing, supported params) gives a provider's model the table does not list yet that provider's current behavior, never its oldest, since providers ship names faster than any table is updated
|
||||
|
||||
Follow conventional commits for commit names and PR titles
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Here are the core requirements for any PR submitted to LiteLLM:
|
|||
|
||||
- [ ] **Sign the Contributor License Agreement (CLA)** - [see details](#contributor-license-agreement-cla)
|
||||
- [ ] **Keep scope isolated** - Your changes should address 1 specific problem at a time
|
||||
- [ ] **Prove it works end to end** - If your change affects what the proxy or SDK does, paste the commands you ran against a live proxy and their output in the PR - [see details](#proving-your-change-works-end-to-end)
|
||||
|
||||
#### Proxy (Backend) PRs
|
||||
|
||||
|
|
@ -248,6 +249,43 @@ If `make test-unit` fails:
|
|||
- **Test edge cases**: Don't just test the happy path
|
||||
- **Update documentation**: If you change APIs, update docs
|
||||
|
||||
## Proving Your Change Works End to End
|
||||
|
||||
Tests are required, but they do not prove that your change works for a user. A PR that changes what the proxy or SDK does shows a real run in its "Screenshots / Proof of Fix" section, meaning the exact commands you sent to a live proxy and the output that came back, against real provider APIs and not mocks. `pytest` output does not count as proof, because a passing test only shows that the code does what its mocks expect. A PR that changes only docs or CI shows the rendered page or the CI run instead
|
||||
|
||||
A good proof looks like this:
|
||||
|
||||
1. Install the proxy dependencies with `make install-proxy-dev`, then start the proxy from your branch with a config that has the model you changed, `uv run litellm --config your_config.yaml --detailed_debug`
|
||||
2. Send the request a user would send with `curl`, and paste both the command and the response. When cost or routing is involved, include the response headers, since `x-litellm-response-cost` is what the user sees
|
||||
3. For a bug fix, do this twice, once at the merge base with BerriAI/litellm's main (`git merge-base origin/main HEAD`, with your remote for BerriAI/litellm in place of `origin` in a fork clone) to show the bug and once at your branch's latest commit to show the fix. Name both commit hashes
|
||||
4. If your change is reachable from more than one endpoint (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`), show each one
|
||||
5. If the request can stream, show a `"stream": true` run too, because streaming builds usage and cost on a different path. A stream sends its headers before its usage exists, so show the `usage.cost` on the last chunk, which `/v1/chat/completions` sends when the request has `"stream_options": {"include_usage": true}` and other endpoints send with `include_cost_in_streaming_usage: true` under `litellm_settings`, or, when the proxy has a database, the request's row on the Admin UI's Logs page or `GET /spend/logs`
|
||||
6. For UI changes, add before and after screenshots and name the page
|
||||
7. If users reach your change through a coding tool such as Claude Code or Codex, drive that tool the way a user does and add before and after screenshots of it, because a `curl` or a headless run alone does not show what the user sees
|
||||
|
||||
```bash
|
||||
curl -sD - http://localhost:4000/v1/chat/completions \
|
||||
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "your-model", "messages": [{"role": "user", "content": "hi"}]}'
|
||||
```
|
||||
|
||||
When the provider reports its own usage or spend numbers, compare the proxy's numbers against them in the proof
|
||||
|
||||
## Contributing an LLM Provider Integration
|
||||
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
### LiteLLM Proxy Server
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -153,7 +153,7 @@ lint-install:
|
|||
# the same set CI's ':(glob)litellm/**/*.py' selects.
|
||||
lint-format-check-changed: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
|
||||
@base_ref=$$($(RESOLVE_BASE)) && \
|
||||
changed=$$(git diff --name-only --diff-filter=ACMR "$$base_ref...HEAD" -- 'litellm/*.py') && \
|
||||
changed=$$(git diff --name-only --diff-filter=ACMR --merge-base "$$base_ref" -- 'litellm/*.py') && \
|
||||
files=$$(printf '%s\n' "$$changed" | grep -v '^litellm/enterprise/' || true) || exit $$?; \
|
||||
if [ -z "$$files" ]; then \
|
||||
echo "No changed litellm Python files to format-check."; \
|
||||
|
|
|
|||
56
README.md
56
README.md
|
|
@ -141,12 +141,56 @@ response = client.chat.completions.create(
|
|||
)
|
||||
```
|
||||
|
||||
[**Docs: LLM Providers**](https://docs.litellm.ai/docs/providers)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Auto Routing</b> - Choose a model for each prompt (Python SDK + AI Gateway)</summary>
|
||||
|
||||
### Python SDK
|
||||
|
||||
[**Docs: Autorouter SDK**](https://docs.litellm.ai/docs/autorouter_sdk)
|
||||
|
||||
Install with `uv add litellm` and set `OPENAI_API_KEY` for the models below.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from litellm import Router
|
||||
|
||||
router = Router(model_list=[
|
||||
{"model_name": "fast", "litellm_params": {"model": "openai/gpt-6-luna"}},
|
||||
{"model_name": "capable", "litellm_params": {"model": "openai/gpt-6.1-sol"}},
|
||||
{"model_name": "auto", "litellm_params": {
|
||||
"model": "auto_router/complexity_router",
|
||||
"complexity_router_config": {
|
||||
"classifier_type": "heuristic",
|
||||
"tiers": {"SIMPLE": "fast", "MEDIUM": "fast",
|
||||
"COMPLEX": "capable", "REASONING": "capable"},
|
||||
"return_raw_model_name": True,
|
||||
},
|
||||
"complexity_router_default_model": "capable",
|
||||
}},
|
||||
])
|
||||
response = asyncio.run(router.acompletion(
|
||||
model="auto", messages=[{"role": "user", "content": "What is 2+2?"}],
|
||||
))
|
||||
print(response.model, response.choices[0].message.content)
|
||||
```
|
||||
|
||||
### AI Gateway (Proxy Server)
|
||||
|
||||
[**Create an Auto Router**](https://docs.litellm.ai/docs/auto_router/setup), then call its model name. Replace `auto` and the URL below with yours, and set `LITELLM_API_KEY` to your virtual key.
|
||||
|
||||
```shell
|
||||
curl http://localhost:4000/v1/chat/completions \
|
||||
-H "Authorization: Bearer $LITELLM_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"auto","messages":[{"role":"user","content":"What is 2+2?"}]}'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Agents</b> - Invoke A2A Agents (Python SDK + AI Gateway)</summary>
|
||||
<summary><b>A2A</b> - Invoke A2A Agents (Python SDK + AI Gateway)</summary>
|
||||
|
||||
[**Supported Providers**](https://docs.litellm.ai/docs/a2a#add-a2a-agents) - LangGraph, Vertex AI Agent Engine, Azure AI Foundry, Bedrock AgentCore, Pydantic AI
|
||||
|
||||
|
|
@ -335,6 +379,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
|
|||
| [Bytez (`bytez`)](https://docs.litellm.ai/docs/providers/bytez) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Cerebras (`cerebras`)](https://docs.litellm.ai/docs/providers/cerebras) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Clarifai (`clarifai`)](https://docs.litellm.ai/docs/providers/clarifai) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [CLF AI Gateway (`clf_ai_gateway`)](https://docs.litellm.ai/docs/providers/clf_ai_gateway) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Cloudflare AI Workers (`cloudflare`)](https://docs.litellm.ai/docs/providers/cloudflare_workers) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Codestral (`codestral`)](https://docs.litellm.ai/docs/providers/codestral) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Cognition (`cognition`)](https://docs.litellm.ai/docs/providers/cognition) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
|
|
@ -353,7 +398,9 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
|
|||
| [Deepseek (`deepseek`)](https://docs.litellm.ai/docs/providers/deepseek) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Eden AI (`edenai`)](https://docs.litellm.ai/docs/providers/edenai) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | |
|
||||
| [ElevenLabs (`elevenlabs`)](https://docs.litellm.ai/docs/providers/elevenlabs) | ✅ | ✅ | ✅ | | | ✅ | ✅ | | | |
|
||||
| [EmpirioLabs AI (`empiriolabs`)](https://docs.litellm.ai/docs/providers/empiriolabs) | ✅ | | ✅ | | | | | | | |
|
||||
| [Empower (`empower`)](https://docs.litellm.ai/docs/providers/empower) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [eu/jev Decisions API (`eu_jev`)](https://docs.litellm.ai/docs/pass_through/eu_jev) | | | | | | | | | | |
|
||||
| [Fal AI (`fal_ai`)](https://docs.litellm.ai/docs/providers/fal_ai) | ✅ | ✅ | ✅ | | ✅ | | | | | |
|
||||
| [Featherless AI (`featherless_ai`)](https://docs.litellm.ai/docs/providers/featherless_ai) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Fireworks AI (`fireworks_ai`)](https://docs.litellm.ai/docs/providers/fireworks_ai) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
|
|
@ -398,6 +445,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
|
|||
| [OpenAI-like (`openai_like`)](https://docs.litellm.ai/docs/providers/openai_compatible) | | | | ✅ | | | | | | |
|
||||
| [OpenRouter (`openrouter`)](https://docs.litellm.ai/docs/providers/openrouter) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [OVHCloud AI Endpoints (`ovhcloud`)](https://docs.litellm.ai/docs/providers/ovhcloud) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Parallel AI (`parallel_ai`)](https://docs.litellm.ai/docs/search/parallel_ai) | | | | | | | | | | |
|
||||
| [Perplexity AI (`perplexity`)](https://docs.litellm.ai/docs/providers/perplexity) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Petals (`petals`)](https://docs.litellm.ai/docs/providers/petals) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Pinstripes (`pinstripes`)](https://docs.litellm.ai/docs/providers/pinstripes) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
|
|
@ -442,7 +490,7 @@ You can use LiteLLM through either the Proxy Server or Python SDK. Both give you
|
|||
<tr>
|
||||
<th style={{width: '14%'}}></th>
|
||||
<th style={{width: '43%'}}><strong><a href="https://docs.litellm.ai/docs/simple_proxy">LiteLLM AI Gateway</a></strong></th>
|
||||
<th style={{width: '43%'}}><strong><a href="https://docs.litellm.ai/docs/">LiteLLM Python SDK</a></strong></th>
|
||||
<th style={{width: '43%'}}><strong><a href="https://docs.litellm.ai/docs/python_sdk">LiteLLM Python SDK</a></strong></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -650,7 +698,7 @@ For companies that need better security, user management and professional suppor
|
|||
[Talk to founders](https://enterprise.litellm.ai/demo)
|
||||
|
||||
This covers:
|
||||
- ✅ **Features under the [LiteLLM Commercial License](https://docs.litellm.ai/docs/proxy/enterprise):**
|
||||
- ✅ **Features under the [LiteLLM Commercial License](https://docs.litellm.ai/docs/enterprise):**
|
||||
- ✅ **Feature Prioritization**
|
||||
- ✅ **Custom Integrations**
|
||||
- ✅ **Professional Support - Dedicated discord + slack**
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ 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 && \
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
|
|||
"/cache_settings",
|
||||
"/coordination_redis/",
|
||||
"/cost_tracking",
|
||||
"/subscription_accounts/",
|
||||
"/cost_optimization/",
|
||||
"/cost/",
|
||||
"/credentials",
|
||||
|
|
|
|||
|
|
@ -1,37 +1,67 @@
|
|||
"""Guard the cost map on pull requests.
|
||||
|
||||
Every pull request whose diff against its merge base touches one of the two cost map files gets the file
|
||||
checks: the files parse and the backup copy matches the root file. A pull request that leaves both untouched
|
||||
skips them, since merging it keeps the base branch's copies and its head tree only carries whatever state the
|
||||
branch was cut from. Pull requests from the cost map sync bot (branches named litellm_cost_map_sync_*) always
|
||||
get the file checks and additionally may only touch those two files and may only add or update models.
|
||||
Every pull request whose diff against its merge base touches one of the two cost map files or the pins file gets the
|
||||
file checks: the files parse, the backup copy matches the root file, and every capability pinned in
|
||||
ci_cd/cost_map_pins.json (a value verified against a live provider call where the provider's model listing says
|
||||
otherwise) still holds in the tree that merging the pull request into its base branch produces, so a branch cut before
|
||||
a pin was added cannot lower it, and a pull request that deletes the pins file fails too. A pull request that leaves
|
||||
all three untouched skips them, since merging it keeps the base branch's copies and its head tree only carries
|
||||
whatever state the branch was cut from. Pull requests from the cost map sync bot (branches named
|
||||
litellm_cost_map_sync_*) always get the file checks and additionally may only touch the two cost map files and may
|
||||
only add or update models, so a sync can never lower a pinned capability or edit the pins. Price sync pull requests
|
||||
(branches named litellm-providers/price-sync*) and pull requests a bot account opened (COST_MAP_GUARD_AUTHOR_TYPE is
|
||||
Bot) get the human checks and may not touch the pins file either.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
COST_MAP_PATH: Final = "model_prices_and_context_window.json"
|
||||
BACKUP_PATH: Final = "litellm/model_prices_and_context_window_backup.json"
|
||||
PINS_PATH: Final = "ci_cd/cost_map_pins.json"
|
||||
GUARDED_PATHS: Final = (COST_MAP_PATH, BACKUP_PATH)
|
||||
CHECKED_PATHS: Final = (*GUARDED_PATHS, PINS_PATH)
|
||||
BOT_BRANCH_PREFIX: Final = "litellm_cost_map_sync_"
|
||||
PRICE_SYNC_BRANCH_PREFIX: Final = "litellm-providers/price-sync"
|
||||
TARGET_ENV: Final = "COST_MAP_GUARD_TARGET"
|
||||
AUTHOR_TYPE_ENV: Final = "COST_MAP_GUARD_AUTHOR_TYPE"
|
||||
BOT_AUTHOR_TYPE: Final = "Bot"
|
||||
MERGE_CONFLICT_EXIT_CODE: Final = 1
|
||||
SPECIAL_ROOT_KEYS: Final = frozenset({"sample_spec", "fallback_generalizations"})
|
||||
EVALUATION_OUTPUT_PRICE_SOURCES: Final[Mapping[str, str]] = MappingProxyType({})
|
||||
|
||||
CostMap = dict[str, object]
|
||||
_MISSING: Final = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Snapshot:
|
||||
cost_map: str
|
||||
backup: str
|
||||
pins: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MergeFailed:
|
||||
stderr: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pin:
|
||||
key: str
|
||||
field: str
|
||||
value: object
|
||||
checked: str
|
||||
source: str
|
||||
|
||||
|
||||
def _parse_object(text: str, path: str) -> CostMap | str:
|
||||
|
|
@ -91,6 +121,48 @@ def _entries(cost_map: CostMap) -> dict[str, dict[str, object]]:
|
|||
return {key: entry for key, entry in cost_map.items() if isinstance(entry, dict)}
|
||||
|
||||
|
||||
def _pin(key: str, field: str, spec: object) -> Pin | str:
|
||||
malformed: Final = f"{PINS_PATH}: {key}.{field} needs value, checked, and source"
|
||||
if not isinstance(spec, dict) or "value" not in spec:
|
||||
return malformed
|
||||
checked: Final = spec.get("checked")
|
||||
source: Final = spec.get("source")
|
||||
if not isinstance(checked, str) or not isinstance(source, str) or not checked or not source:
|
||||
return malformed
|
||||
return Pin(key, field, spec["value"], checked, source)
|
||||
|
||||
|
||||
def _pins(parsed: CostMap) -> Iterator[Pin | str]:
|
||||
for key, fields in parsed.items():
|
||||
if not isinstance(fields, dict):
|
||||
yield f"{PINS_PATH}: {key} must map field names to pins"
|
||||
continue
|
||||
for field, spec in fields.items():
|
||||
yield _pin(key, field, spec)
|
||||
|
||||
|
||||
def _pin_violations(parsed: CostMap, entries: Mapping[str, Mapping[str, object]]) -> Iterator[str]:
|
||||
for pin in _pins(parsed):
|
||||
if isinstance(pin, str):
|
||||
yield pin
|
||||
elif pin.key not in entries:
|
||||
yield f"{PINS_PATH} pins {pin.key}.{pin.field} but {COST_MAP_PATH} has no {pin.key} entry"
|
||||
elif entries[pin.key].get(pin.field, _MISSING) != pin.value:
|
||||
yield (
|
||||
f"{COST_MAP_PATH}: {pin.key}.{pin.field} must stay {json.dumps(pin.value)} "
|
||||
f"(verified {pin.checked}: {pin.source}); change {PINS_PATH} with new evidence first"
|
||||
)
|
||||
|
||||
|
||||
def pin_failures(pins_text: str, head_map: CostMap) -> tuple[str, ...]:
|
||||
if not pins_text:
|
||||
return (f"{PINS_PATH} is missing; restore it, its pins were verified against live provider calls",)
|
||||
parsed: Final = _parse_object(pins_text, PINS_PATH)
|
||||
if isinstance(parsed, str):
|
||||
return (parsed,)
|
||||
return tuple(_pin_violations(parsed, _entries(head_map)))
|
||||
|
||||
|
||||
def _bot_failures(base: Snapshot, head_map: CostMap, changed_files: Sequence[str]) -> tuple[str, ...]:
|
||||
base_map: Final = _parse_object(base.cost_map, COST_MAP_PATH)
|
||||
if isinstance(base_map, str):
|
||||
|
|
@ -120,8 +192,18 @@ def _bot_failures(base: Snapshot, head_map: CostMap, changed_files: Sequence[str
|
|||
)
|
||||
|
||||
|
||||
def pin_author_failures(head_ref: str, author_type: str, changed_files: Sequence[str]) -> tuple[str, ...]:
|
||||
if PINS_PATH not in changed_files:
|
||||
return ()
|
||||
if head_ref.startswith(PRICE_SYNC_BRANCH_PREFIX):
|
||||
return (f"price sync PRs may not change {PINS_PATH}; a pin moves only in a human PR with new evidence",)
|
||||
if author_type == BOT_AUTHOR_TYPE:
|
||||
return (f"PRs opened by a bot may not change {PINS_PATH}; a pin moves only in a human PR with new evidence",)
|
||||
return ()
|
||||
|
||||
|
||||
def touches_cost_map(changed_files: Sequence[str]) -> bool:
|
||||
return any(path in GUARDED_PATHS for path in changed_files)
|
||||
return any(path in CHECKED_PATHS for path in changed_files)
|
||||
|
||||
|
||||
def contract_for(bot: bool, changed_files: Sequence[str]) -> str:
|
||||
|
|
@ -130,9 +212,21 @@ def contract_for(bot: bool, changed_files: Sequence[str]) -> str:
|
|||
return "human PR, file checks only" if touches_cost_map(changed_files) else "human PR, cost map untouched"
|
||||
|
||||
|
||||
def _merged_pin_failures(base: Snapshot, merged: Snapshot | None) -> tuple[str, ...]:
|
||||
if merged is None:
|
||||
return ("the pull request does not merge cleanly into its base branch; merge the base branch in first",)
|
||||
if not merged.pins and not base.pins:
|
||||
return ()
|
||||
merged_map: Final = _parse_object(merged.cost_map, COST_MAP_PATH)
|
||||
if isinstance(merged_map, str):
|
||||
return (f"after merging into the base branch, {merged_map}",)
|
||||
return pin_failures(merged.pins, merged_map)
|
||||
|
||||
|
||||
def guard_failures(
|
||||
base: Snapshot,
|
||||
head: Snapshot,
|
||||
merged: Snapshot | None,
|
||||
changed_files: Sequence[str],
|
||||
bot: bool,
|
||||
*,
|
||||
|
|
@ -147,6 +241,7 @@ def guard_failures(
|
|||
*_file_failures(
|
||||
head, head_map, evaluation_output_price_sources=evaluation_output_price_sources
|
||||
),
|
||||
*_merged_pin_failures(base, merged),
|
||||
*(_bot_failures(base, head_map, changed_files) if bot else ()),
|
||||
)
|
||||
|
||||
|
|
@ -157,7 +252,22 @@ def _git(*args: str) -> str | None:
|
|||
|
||||
|
||||
def snapshot(revision: str) -> Snapshot:
|
||||
return Snapshot(*(_git("show", f"{revision}:{path}") or "" for path in GUARDED_PATHS))
|
||||
return Snapshot(*(_git("show", f"{revision}:{path}") or "" for path in CHECKED_PATHS))
|
||||
|
||||
|
||||
def merged_tree(merge_base: str, target: str, head: str) -> str | MergeFailed | None:
|
||||
result: Final = subprocess.run(
|
||||
("git", "merge-tree", "--write-tree", f"--merge-base={merge_base}", target, head),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
tree: Final = result.stdout.split("\n", 1)[0]
|
||||
if result.returncode == 0:
|
||||
return tree
|
||||
if result.returncode == MERGE_CONFLICT_EXIT_CODE and tree:
|
||||
return None
|
||||
return MergeFailed(result.stderr.strip())
|
||||
|
||||
|
||||
def changed_files(base: str, head: str) -> tuple[str, ...] | None:
|
||||
|
|
@ -169,6 +279,13 @@ def main(argv: Sequence[str]) -> int:
|
|||
parser: Final = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base", required=True, help="merge base of the pull request")
|
||||
parser.add_argument("--head", required=True, help="head commit of the pull request")
|
||||
env_target: Final = os.environ.get(TARGET_ENV)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
default=env_target,
|
||||
required=env_target is None,
|
||||
help=f"tip of the base branch the pull request merges into, defaulting to ${TARGET_ENV}",
|
||||
)
|
||||
parser.add_argument("--head-ref", required=True, help="head branch name of the pull request")
|
||||
args: Final = parser.parse_args(argv)
|
||||
bot: Final = args.head_ref.startswith(BOT_BRANCH_PREFIX)
|
||||
|
|
@ -176,7 +293,19 @@ def main(argv: Sequence[str]) -> int:
|
|||
if changed is None:
|
||||
print(f"cost map guard failed: git diff {args.base} {args.head} failed, so the changed files are unknown")
|
||||
return 1
|
||||
failures: Final = guard_failures(snapshot(args.base), snapshot(args.head), changed, bot)
|
||||
if not bot and not touches_cost_map(changed):
|
||||
print(f"cost map guard passed ({contract_for(bot, changed)})")
|
||||
return 0
|
||||
merged: Final = merged_tree(args.base, args.target, args.head)
|
||||
if isinstance(merged, MergeFailed):
|
||||
print(f"cost map guard failed: git merge-tree into {args.target} failed: {merged.stderr}")
|
||||
return 1
|
||||
failures: Final = (
|
||||
*guard_failures(
|
||||
snapshot(args.base), snapshot(args.head), None if merged is None else snapshot(merged), changed, bot
|
||||
),
|
||||
*pin_author_failures(args.head_ref, os.environ.get(AUTHOR_TYPE_ENV, ""), changed),
|
||||
)
|
||||
contract: Final = contract_for(bot, changed)
|
||||
if failures:
|
||||
print(f"cost map guard failed ({contract}):")
|
||||
|
|
|
|||
16
ci_cd/cost_map_pins.json
Normal file
16
ci_cd/cost_map_pins.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"fireworks_ai/accounts/fireworks/models/minimax-m3": {
|
||||
"supports_vision": {
|
||||
"value": true,
|
||||
"checked": "2026-09-26",
|
||||
"source": "POST https://api.fireworks.ai/inference/v1/chat/completions read a 512x512 PNG back as digit=7, shape=circle, color=red while GET https://api.fireworks.ai/inference/v1/models still said supports_image_input false and GET https://api.fireworks.ai/v1/serverless/models?format=nested carried no supportsImageInput on the row"
|
||||
}
|
||||
},
|
||||
"fireworks_ai/minimax-m3": {
|
||||
"supports_vision": {
|
||||
"value": true,
|
||||
"checked": "2026-09-26",
|
||||
"source": "POST https://api.fireworks.ai/inference/v1/chat/completions read a 512x512 PNG back as digit=7, shape=circle, color=red while GET https://api.fireworks.ai/inference/v1/models still said supports_image_input false and GET https://api.fireworks.ai/v1/serverless/models?format=nested carried no supportsImageInput on the row"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
codecov.yaml
11
codecov.yaml
|
|
@ -15,9 +15,10 @@ ignore:
|
|||
flag_management:
|
||||
default_rules:
|
||||
carryforward: true
|
||||
# Dead flags no CI job uploads anymore: their carried-forward sessions were
|
||||
# measured against old revisions, and the stale line maps mark comment lines
|
||||
# of since-edited files as missed, sinking patch coverage on unrelated PRs.
|
||||
# Dead flags no CI job uploads anymore, and flags whose job is path-gated on
|
||||
# PRs: their carried-forward sessions were measured against old revisions, and
|
||||
# the stale line maps mark comment lines of since-edited files as missed,
|
||||
# sinking patch coverage on unrelated PRs.
|
||||
individual_flags:
|
||||
- name: proxy-mgmt-behavior
|
||||
carryforward: false
|
||||
|
|
@ -99,6 +100,10 @@ flag_management:
|
|||
carryforward: false
|
||||
- name: proxy-db-endpoints-and-responses
|
||||
carryforward: false
|
||||
- name: roi-postgres
|
||||
carryforward: false
|
||||
- name: redis-compat
|
||||
carryforward: false
|
||||
|
||||
component_management:
|
||||
individual_components:
|
||||
|
|
|
|||
245
cookbook/gpt_realtime_translate.py
Normal file
245
cookbook/gpt_realtime_translate.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import wave
|
||||
from collections.abc import Iterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
import websockets
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
|
||||
SAMPLE_RATE = 24_000
|
||||
CHANNELS = 1
|
||||
SAMPLE_WIDTH = 2
|
||||
CHUNK_DURATION_SECONDS = 0.1
|
||||
CHUNK_BYTES = int(SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH * CHUNK_DURATION_SECONDS)
|
||||
RESPONSE_TIMEOUT_SECONDS = 30.0
|
||||
AUDIO_EVENT_TYPES = frozenset(
|
||||
{
|
||||
"session.output_audio.delta",
|
||||
"response.audio.delta",
|
||||
"response.output_audio.delta",
|
||||
}
|
||||
)
|
||||
TRANSCRIPT_EVENT_TYPES = frozenset(
|
||||
{
|
||||
"session.output_transcript.delta",
|
||||
"response.text.delta",
|
||||
"response.output_audio_transcript.delta",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Settings:
|
||||
input_wav: Path
|
||||
output_wav: Path
|
||||
base_url: str
|
||||
model: str
|
||||
target_language: str
|
||||
trailing_silence_seconds: float
|
||||
api_key: str
|
||||
|
||||
|
||||
def write_stdout(message: str = "", *, end: str = "\n", flush: bool = False) -> None:
|
||||
sys.stdout.write(f"{message}{end}")
|
||||
if flush:
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def write_stderr(message: str) -> None:
|
||||
sys.stderr.write(f"{message}\n")
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> Settings | str:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Stream a 24 kHz PCM16 WAV through gpt-realtime-translate and save the translated audio",
|
||||
)
|
||||
parser.add_argument("input_wav", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=Path("translated.wav"))
|
||||
parser.add_argument("--base-url", default=os.getenv("LITELLM_BASE_URL", "http://localhost:4000"))
|
||||
parser.add_argument("--model", default=os.getenv("REALTIME_TRANSLATE_MODEL", "gpt-realtime-translate"))
|
||||
parser.add_argument("--target-language", default="fr")
|
||||
parser.add_argument("--trailing-silence", type=float, default=1.5)
|
||||
parsed = parser.parse_args(argv)
|
||||
api_key = os.getenv("LITELLM_API_KEY") or os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
return "Set LITELLM_API_KEY or OPENAI_API_KEY before running the script"
|
||||
if parsed.trailing_silence < 0:
|
||||
return "--trailing-silence must be zero or greater"
|
||||
return Settings(
|
||||
input_wav=parsed.input_wav,
|
||||
output_wav=parsed.output,
|
||||
base_url=parsed.base_url,
|
||||
model=parsed.model,
|
||||
target_language=parsed.target_language,
|
||||
trailing_silence_seconds=parsed.trailing_silence,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
|
||||
def translation_url(base_url: str, model: str) -> str | None:
|
||||
parsed = urlsplit(base_url.rstrip("/"))
|
||||
scheme = {"http": "ws", "https": "wss", "ws": "ws", "wss": "wss"}.get(parsed.scheme)
|
||||
if not scheme or not parsed.netloc:
|
||||
return None
|
||||
base_path = parsed.path.rstrip("/")
|
||||
realtime_path = (
|
||||
f"{base_path}/realtime/translations" if base_path.endswith("/v1") else f"{base_path}/v1/realtime/translations"
|
||||
)
|
||||
return urlunsplit((scheme, parsed.netloc, realtime_path, urlencode({"model": model}), ""))
|
||||
|
||||
|
||||
def read_pcm16_wav(path: Path) -> bytes | str:
|
||||
try:
|
||||
with wave.open(str(path), "rb") as source:
|
||||
actual_format = (
|
||||
source.getnchannels(),
|
||||
source.getsampwidth(),
|
||||
source.getframerate(),
|
||||
source.getcomptype(),
|
||||
)
|
||||
expected_format = (CHANNELS, SAMPLE_WIDTH, SAMPLE_RATE, "NONE")
|
||||
if actual_format != expected_format:
|
||||
return (
|
||||
f"{path} must be mono, 16-bit PCM, 24 kHz WAV; received "
|
||||
f"channels={actual_format[0]}, sample_width={actual_format[1]}, "
|
||||
f"sample_rate={actual_format[2]}, compression={actual_format[3]}"
|
||||
)
|
||||
return source.readframes(source.getnframes())
|
||||
except (OSError, EOFError, wave.Error) as exc:
|
||||
return f"Unable to read {path}: {exc}"
|
||||
|
||||
|
||||
def audio_chunks(audio: bytes) -> Iterator[bytes]:
|
||||
return (audio[offset : offset + CHUNK_BYTES] for offset in range(0, len(audio), CHUNK_BYTES))
|
||||
|
||||
|
||||
def audio_message(audio: bytes) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "session.input_audio_buffer.append",
|
||||
"audio": base64.b64encode(audio).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def configure_session(connection: ClientConnection, target_language: str) -> str | None:
|
||||
await connection.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {"audio": {"output": {"language": target_language}}},
|
||||
}
|
||||
)
|
||||
)
|
||||
while True:
|
||||
raw_event = await asyncio.wait_for(connection.recv(), timeout=20)
|
||||
event = json.loads(raw_event)
|
||||
event_type = event.get("type")
|
||||
if event_type == "session.created":
|
||||
write_stdout(f"Session: {event.get('session', {}).get('id', 'created')}")
|
||||
if event_type == "session.updated":
|
||||
return None
|
||||
if event_type == "error":
|
||||
return f"Realtime API error: {json.dumps(event.get('error', event), ensure_ascii=False)}"
|
||||
|
||||
|
||||
async def send_audio(
|
||||
connection: ClientConnection, pcm: bytes, trailing_silence_seconds: float, finished: asyncio.Event
|
||||
) -> None:
|
||||
silence = bytes(round(trailing_silence_seconds * SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH))
|
||||
try:
|
||||
for chunk in audio_chunks(pcm + silence):
|
||||
await connection.send(audio_message(chunk))
|
||||
await asyncio.sleep(len(chunk) / (SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH))
|
||||
await connection.send(json.dumps({"type": "session.close"}))
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
|
||||
async def receive_translation(
|
||||
connection: ClientConnection, output_path: Path, sender_finished: asyncio.Event
|
||||
) -> str | None:
|
||||
audio_received = asyncio.Event()
|
||||
try:
|
||||
with wave.open(str(output_path), "wb") as output:
|
||||
output.setnchannels(CHANNELS)
|
||||
output.setsampwidth(SAMPLE_WIDTH)
|
||||
output.setframerate(SAMPLE_RATE)
|
||||
write_stdout("Translation: ", end="", flush=True)
|
||||
while True:
|
||||
try:
|
||||
raw_event = await asyncio.wait_for(connection.recv(), timeout=RESPONSE_TIMEOUT_SECONDS)
|
||||
except TimeoutError:
|
||||
if not sender_finished.is_set():
|
||||
continue
|
||||
return (
|
||||
f"The translation stream went quiet for {RESPONSE_TIMEOUT_SECONDS:g} s without closing the session"
|
||||
)
|
||||
event = json.loads(raw_event)
|
||||
event_type = event.get("type")
|
||||
if event_type in AUDIO_EVENT_TYPES:
|
||||
output.writeframes(base64.b64decode(event.get("delta", ""), validate=True))
|
||||
audio_received.set()
|
||||
elif event_type in TRANSCRIPT_EVENT_TYPES:
|
||||
write_stdout(event.get("delta", event.get("text", "")), end="", flush=True)
|
||||
elif event_type == "error":
|
||||
return f"Realtime API error: {json.dumps(event.get('error', event), ensure_ascii=False)}"
|
||||
elif event_type == "session.closed":
|
||||
write_stdout()
|
||||
return None if audio_received.is_set() else "The translation stream ended without translated audio"
|
||||
except (OSError, wave.Error) as exc:
|
||||
return f"Unable to write {output_path}: {exc}"
|
||||
|
||||
|
||||
async def translate(settings: Settings, pcm: bytes) -> str | None:
|
||||
url = translation_url(settings.base_url, settings.model)
|
||||
if not url:
|
||||
return f"Invalid --base-url: {settings.base_url}"
|
||||
sender_finished = asyncio.Event()
|
||||
try:
|
||||
async with websockets.connect(
|
||||
url,
|
||||
additional_headers={"Authorization": f"Bearer {settings.api_key}"},
|
||||
proxy=None,
|
||||
open_timeout=20,
|
||||
close_timeout=5,
|
||||
) as connection:
|
||||
configuration_error = await configure_session(connection, settings.target_language)
|
||||
if configuration_error:
|
||||
return configuration_error
|
||||
async with asyncio.TaskGroup() as tasks:
|
||||
receiver = tasks.create_task(receive_translation(connection, settings.output_wav, sender_finished))
|
||||
tasks.create_task(send_audio(connection, pcm, settings.trailing_silence_seconds, sender_finished))
|
||||
return receiver.result()
|
||||
except Exception as exc:
|
||||
return f"Translation failed: {type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
settings = parse_args(argv)
|
||||
if isinstance(settings, str):
|
||||
write_stderr(settings)
|
||||
return 2
|
||||
pcm = read_pcm16_wav(settings.input_wav)
|
||||
if isinstance(pcm, str):
|
||||
write_stderr(pcm)
|
||||
return 2
|
||||
error = asyncio.run(translate(settings, pcm))
|
||||
if error:
|
||||
write_stderr(error)
|
||||
return 1
|
||||
write_stdout(f"Translated audio: {settings.output_wav}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
5
docs/adrs/AGENTS.md
Normal file
5
docs/adrs/AGENTS.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Architecture decision records for the Python SDK and proxy
|
||||
|
||||
This folder records decisions about the Python SDK and proxy the way [`litellm-rust/docs/adrs/`](../../litellm-rust/docs/adrs/AGENTS.md) records them for the Rust core, and that file's process applies here unchanged: one short `adr_NNN_short_title.md` per decision that is hard to reverse or that a new contributor would reasonably question, with Context, Decision, Alternatives Considered, and Consequences, superseded by a new ADR instead of edited once accepted. Each folder numbers its own ADRs, so a reference to an ADR in the other folder names its path
|
||||
|
||||
Read the ADRs here before you build a new way to move data between the endpoints, the provider transformations, and cost tracking or logging. A PR that makes a decision of that kind adds its ADR in the same PR
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# ADR 001: Provider usage fields ride as extras on the normalized usage object
|
||||
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Providers report usage fields that no other provider has, such as xAI's `usage.server_side_tool_usage_details.web_search_calls` or Anthropic's `usage.inference_geo`, and cost tracking has to read them or that spend never reaches `LiteLLM_SpendLogs`. One provider response can be served on `/v1/chat/completions`, `/v1/responses`, and `/v1/messages`, streaming or not, so a field that only one of those paths carries is billed wrong on the others
|
||||
|
||||
[#30817](https://github.com/BerriAI/litellm/pull/30817) fixed unbilled xAI web searches. One draft did the copy in the xAI Responses transform to keep provider code out of shared files, and that swapped `/v1/responses` usage to the chat shape, so callers of that endpoint lost the Responses schema. The merged version keeps the schema, copies unknown usage fields in the shared Responses-to-chat bridge for every provider, and gates on the reported field without naming the provider
|
||||
|
||||
In October 2026, `Usage` and `ResponseAPIUsage` kept unknown usage keys as pydantic extras, and that bridge copied them. Paths that build usage from named fields kept only the fields they name: the Anthropic, Gemini, and Bedrock Converse transforms, the stream builder, and the bridges from chat usage to `/v1/responses` and `/v1/messages`
|
||||
|
||||
## Decision
|
||||
|
||||
Provider-only usage fields stay on the usage object as pydantic extras, and cost tracking reads them there, in that provider's cost calculator or in a shared gate that checks the field instead of the provider. An extra reaches callers as an added usage key. No transform renames, removes, or reshapes a usage field the endpoint's schema defines to feed cost tracking, though it may fill one LiteLLM's usage object already defines, such as `server_tool_use`
|
||||
|
||||
A shared path that rebuilds usage, such as the stream builder or a bridge between endpoint shapes, copies the extras instead of naming provider fields. A provider transform that builds usage from named fields names the new field. Neither is bypassed with a side channel. Older code that differs, such as the Azure branches in the shared tracker and the Gemini pricing tier read from `_hidden_params`, predates this ADR
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
**Reshape usage in the provider transform**, as a draft of #30817 did. It changes the schema that callers of that endpoint rely on, and every provider and endpoint needs its own copy
|
||||
|
||||
**A typed field on `ResponseAPIUsage` and `Usage` for each provider's quirk**. The types grow without bound and every bridge needs an update for each one. A field several providers report, like `server_tool_use`, does get one
|
||||
|
||||
**A side channel such as `_hidden_params`**. Cost calculators receive `Usage`, so a side channel makes a second source of truth for the same number
|
||||
|
||||
**Provider branches or provider field names in shared code**. They put one provider's logic in a file every provider shares, which is why #30817 replaced its xAI-only gate with one that checks the reported field
|
||||
|
||||
## Consequences
|
||||
|
||||
Benefits:
|
||||
|
||||
- Billing a new provider usage field touches that provider's calculator (plus a `cost_per_token` arm when the calculator is new), its transform when that names fields, and for a built-in tool at most one gate and one `get_cost_for_web_search_request` arm
|
||||
|
||||
Costs:
|
||||
|
||||
- Extras are untyped, so a reader validates them before use, as `_usage_reports_server_side_web_search_calls` does
|
||||
- The stream builder and the bridges from chat usage did not copy extras when this was written, so until they do, a field billed on those paths is named in them, and tests and proof runs cover each endpoint and `"stream": true`
|
||||
- This covers usage fields LiteLLM prices, not request-side params or a provider's own charge for the request, which OpenRouter and opencode Go report and `response_cost_calculator` reads from `_hidden_params` first
|
||||
|
|
@ -15,7 +15,10 @@ import litellm
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails._content_utils import iter_message_text
|
||||
from litellm.proxy.guardrails._content_utils import (
|
||||
is_non_conversational_request,
|
||||
iter_message_text,
|
||||
)
|
||||
from litellm.types.utils import CallTypesLiteral
|
||||
|
||||
_THRESHOLD_VALUE: Final = TypeAdapter(object)
|
||||
|
|
@ -94,7 +97,7 @@ class ENTERPRISE_GoogleTextModeration(CustomLogger):
|
|||
|
||||
async def async_moderation_hook(
|
||||
self,
|
||||
data: dict,
|
||||
data: dict[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
call_type: CallTypesLiteral,
|
||||
):
|
||||
|
|
@ -102,6 +105,9 @@ class ENTERPRISE_GoogleTextModeration(CustomLogger):
|
|||
- Calls Google's Text Moderation API
|
||||
- Rejects request if it fails safety check
|
||||
"""
|
||||
if is_non_conversational_request(call_type, data):
|
||||
verbose_proxy_logger.debug("Google text moderation: skipping non-conversational call type %s", call_type)
|
||||
return
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
text = "".join(iter_message_text(data))
|
||||
if text:
|
||||
|
|
|
|||
|
|
@ -33,9 +33,8 @@ class EnterpriseCallbackControls:
|
|||
|
||||
try:
|
||||
disabled_callbacks = EnterpriseCallbackControls.get_disabled_callbacks(litellm_params, standard_callback_dynamic_params)
|
||||
verbose_logger.debug(f"Dynamically disabled callbacks from {X_LITELLM_DISABLE_CALLBACKS}: {disabled_callbacks}")
|
||||
verbose_logger.debug(f"Checking if {callback} is disabled via headers. Disable callbacks from headers: {disabled_callbacks}")
|
||||
if disabled_callbacks is not None:
|
||||
verbose_logger.debug(f"Checking if {callback} is in dynamically disabled callbacks: {disabled_callbacks}")
|
||||
#########################################################
|
||||
# premium user check
|
||||
#########################################################
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from dataclasses import replace as dataclasses_replace
|
|||
from datetime import datetime, timedelta
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, List, Literal, Optional, Protocol, Tuple, cast
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm_enterprise.proxy.common_utils.managed_object_attribution import (
|
||||
ManagedObjectCreatorAttribution,
|
||||
|
|
@ -50,6 +51,7 @@ def _error_names_resource(error: Exception, resource_id: str) -> bool:
|
|||
CHECK_BATCH_COST_USER_AGENT = "LiteLLM Proxy/CheckBatchCost"
|
||||
RECENT_BATCH_POLL_WINDOW: Final = timedelta(hours=48)
|
||||
GIVE_UP_POLLING_AFTER: Final = timedelta(days=2 * MANAGED_OBJECT_STALENESS_CUTOFF_DAYS)
|
||||
_FILE_OBJECT_ADAPTER: Final[TypeAdapter[dict[str, object]]] = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
PROVIDER_TERMINAL_BATCH_STATUSES: Final[Tuple[str, ...]] = (
|
||||
|
|
@ -398,7 +400,7 @@ class CheckBatchCost:
|
|||
deployment. Once that deployment leaves the router, default fallbacks can
|
||||
silently send the retrieve to a provider that never saw the batch, so its
|
||||
404 must not retire a potentially recoverable row."""
|
||||
return self.llm_router.get_deployment(model_id=model_id) is not None
|
||||
return self.llm_router.registry().deployment(model_id) is not None
|
||||
|
||||
@staticmethod
|
||||
def _is_output_file_gone_at_provider(error: Exception, output_file_id: Optional[str]) -> bool:
|
||||
|
|
@ -550,6 +552,10 @@ class CheckBatchCost:
|
|||
llm_provider: str,
|
||||
bare_model_name: str,
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
stored_model_id: Final = self._get_stored_model_id(job)
|
||||
if stored_model_id is not None and self._batch_deployment_exists(stored_model_id):
|
||||
return stored_model_id, job.unified_object_id
|
||||
|
||||
deployment_id = self._get_deployment_id_for_bare_model(bare_model_name, llm_provider)
|
||||
if deployment_id is None:
|
||||
verbose_proxy_logger.info(
|
||||
|
|
@ -564,7 +570,7 @@ class CheckBatchCost:
|
|||
def _get_deployment_id_for_bare_model(
|
||||
self, bare_model_name: str, llm_provider: str
|
||||
) -> Optional[str]:
|
||||
model_group = self.llm_router.resolve_model_name_from_model_id(bare_model_name)
|
||||
model_group = self.llm_router.registry().resolve(bare_model_name, kind="from_id")
|
||||
deployment_id = (
|
||||
self._get_deployment_id_for_provider(model_group, llm_provider) if model_group else None
|
||||
)
|
||||
|
|
@ -580,7 +586,7 @@ class CheckBatchCost:
|
|||
) -> Optional[str]:
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
for deployment in self.llm_router.get_model_list(model_name=None) or []:
|
||||
for deployment in self.llm_router.registry().deployments() or []:
|
||||
litellm_params = deployment.get("litellm_params") or {}
|
||||
actual_model = litellm_params.get("model")
|
||||
if not isinstance(actual_model, str):
|
||||
|
|
@ -623,8 +629,9 @@ class CheckBatchCost:
|
|||
"""
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
for deployment_id in self.llm_router.get_model_ids(model_name=model_group):
|
||||
deployment_info = self.llm_router.get_deployment(model_id=deployment_id)
|
||||
registry = self.llm_router.registry()
|
||||
for deployment_id in registry.names(model_group, kind="ids"):
|
||||
deployment_info = registry.deployment(deployment_id)
|
||||
if deployment_info is None:
|
||||
continue
|
||||
try:
|
||||
|
|
@ -661,28 +668,41 @@ class CheckBatchCost:
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_input_file_id(job: "ManagedObjectRow") -> Optional[str]:
|
||||
stored_batch: Final = CheckBatchCost._stored_batch(job)
|
||||
return stored_batch.input_file_id if stored_batch is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _stored_batch(job: "ManagedObjectRow") -> Optional["LiteLLMBatch"]:
|
||||
from litellm.types.utils import LiteLLMBatch
|
||||
|
||||
def _parse_file_object(job: "ManagedObjectRow") -> Optional[dict[str, object]]:
|
||||
file_object: Final = job.file_object
|
||||
try:
|
||||
parsed_file_object: Final[object] = (
|
||||
return _FILE_OBJECT_ADAPTER.validate_python(
|
||||
json.loads(file_object) if isinstance(file_object, str) else file_object
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
except (json.JSONDecodeError, ValueError, ValidationError):
|
||||
return None
|
||||
if not isinstance(parsed_file_object, dict):
|
||||
|
||||
@classmethod
|
||||
def _stored_batch(cls, job: "ManagedObjectRow") -> Optional["LiteLLMBatch"]:
|
||||
from litellm.types.utils import LiteLLMBatch
|
||||
|
||||
parsed_file_object: Final = cls._parse_file_object(job)
|
||||
if parsed_file_object is None:
|
||||
return None
|
||||
try:
|
||||
return LiteLLMBatch.model_validate(parsed_file_object)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _get_input_file_id(cls, job: "ManagedObjectRow") -> Optional[str]:
|
||||
stored_batch: Final = cls._stored_batch(job)
|
||||
return stored_batch.input_file_id if stored_batch is not None else None
|
||||
|
||||
@classmethod
|
||||
def _get_stored_model_id(cls, job: "ManagedObjectRow") -> Optional[str]:
|
||||
"""The deployment that created the batch, written into file_object by the managed
|
||||
files hook at create time. Older rows have no such key and fall back to matching
|
||||
the bare model name against the router."""
|
||||
parsed_file_object: Final = cls._parse_file_object(job)
|
||||
model_id: Final = parsed_file_object.get("model_id") if parsed_file_object else None
|
||||
return model_id if isinstance(model_id, str) and model_id else None
|
||||
|
||||
async def _track_completed_batch_cost(
|
||||
self,
|
||||
job: "ManagedObjectRow",
|
||||
|
|
@ -748,7 +768,7 @@ class CheckBatchCost:
|
|||
except (IndexError, AttributeError):
|
||||
pass
|
||||
|
||||
credentials: Final = self.llm_router.get_deployment_credentials_with_provider(model_id) or {}
|
||||
credentials: Final = self.llm_router.registry().credentials(model_id, form="with_provider") or {}
|
||||
_file_content = await afile_content(
|
||||
file_id=cast(str, raw_output_file_id),
|
||||
_litellm_internal_model_credentials=MappingProxyType(dict(credentials)),
|
||||
|
|
@ -771,7 +791,7 @@ class CheckBatchCost:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
deployment_info = self.llm_router.get_deployment(model_id=model_id)
|
||||
deployment_info = self.llm_router.registry().deployment(model_id)
|
||||
if deployment_info is None:
|
||||
verbose_proxy_logger.info(
|
||||
f"Skipping job {job.unified_object_id} because it is not a valid deployment info"
|
||||
|
|
@ -1001,7 +1021,7 @@ class CheckBatchCost:
|
|||
)
|
||||
|
||||
try:
|
||||
deployment_info: Final = self.llm_router.get_deployment(model_id=model_id)
|
||||
deployment_info: Final = self.llm_router.registry().deployment(model_id)
|
||||
if deployment_info is None:
|
||||
verbose_proxy_logger.error(
|
||||
f"CheckBatchCost: deployment {model_id} is no longer in the router, so the line items of batch "
|
||||
|
|
@ -1014,7 +1034,7 @@ class CheckBatchCost:
|
|||
custom_llm_provider=deployment_info.litellm_params.custom_llm_provider,
|
||||
)
|
||||
cost_provider: Final = cast(BatchFileProvider, llm_provider)
|
||||
credentials: Final = self.llm_router.get_deployment_credentials_with_provider(model_id) or {}
|
||||
credentials: Final = self.llm_router.registry().credentials(model_id, form="with_provider") or {}
|
||||
file_access: Final = {
|
||||
**credentials,
|
||||
"_litellm_internal_model_credentials": MappingProxyType(dict(credentials)),
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from collections.abc import Awaitable, Callable, Mapping
|
|||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from litellm_enterprise.proxy.common_utils.managed_object_attribution import (
|
||||
ManagedObjectCreatorAttribution,
|
||||
|
|
@ -273,7 +273,7 @@ class CheckResponsesCost:
|
|||
|
||||
def _resolve_deployment(self, response_id: str) -> Deployment | None:
|
||||
model_id: str | None = ResponsesAPIRequestUtils.get_model_id_from_response_id(response_id)
|
||||
return self.llm_router.get_deployment(model_id=model_id) if model_id is not None else None
|
||||
return self.llm_router.registry().deployment(model_id) if model_id is not None else None
|
||||
|
||||
async def _get_response(
|
||||
self,
|
||||
|
|
@ -290,12 +290,14 @@ class CheckResponsesCost:
|
|||
sees provider env vars, so it fails for every deployment whose credentials
|
||||
live in the config; the row then never leaves ``queued``.
|
||||
"""
|
||||
if not via_router:
|
||||
return await litellm.aget_responses(response_id=response_id, litellm_metadata=litellm_metadata)
|
||||
router_response = await self.llm_router.aget_responses(
|
||||
response_id=response_id, litellm_metadata=litellm_metadata
|
||||
retrieved: Final[object] = (
|
||||
await self.llm_router.aget_responses(response_id=response_id, litellm_metadata=litellm_metadata)
|
||||
if via_router
|
||||
else await litellm.aget_responses(response_id=response_id, litellm_metadata=litellm_metadata)
|
||||
)
|
||||
return cast(ResponsesAPIResponse, router_response)
|
||||
if not isinstance(retrieved, ResponsesAPIResponse):
|
||||
raise TypeError(f"Retrieving {response_id} did not return a response object")
|
||||
return retrieved
|
||||
|
||||
async def _fetch_unbilled(self, target: _PollTarget) -> ResponsesAPIResponse:
|
||||
return await self._get_response(
|
||||
|
|
@ -413,7 +415,7 @@ class CheckResponsesCost:
|
|||
if target.deployment is None or not isinstance(requested, str):
|
||||
return MappingProxyType({})
|
||||
routed: Final = _ROUTED_DEPLOYMENTS.validate_python(
|
||||
self.llm_router.get_model_list(model_name=requested, team_id=team_id) or ()
|
||||
self.llm_router.registry().deployments(requested, team_id=team_id) or ()
|
||||
)
|
||||
serving_id: Final = target.deployment.model_info.id
|
||||
return MappingProxyType(
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
|
|
@ -21,6 +23,7 @@ from typing import (
|
|||
TypedDict,
|
||||
Union,
|
||||
cast,
|
||||
runtime_checkable,
|
||||
)
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
|
|
@ -33,7 +36,7 @@ from typing_extensions import ReadOnly, Unpack
|
|||
|
||||
import litellm
|
||||
from litellm import Router, verbose_logger
|
||||
from litellm._internal_context import with_service_target
|
||||
from litellm._internal_context import internal_sub_call, post_response_phase, with_service_target
|
||||
from litellm._uuid import uuid
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.constants import (
|
||||
|
|
@ -94,6 +97,7 @@ from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attributio
|
|||
request_tags_from_metadata,
|
||||
)
|
||||
from litellm.repositories.managed_file_repository import ManagedFileRepository
|
||||
from litellm.responses.streaming_iterator import spawn_logging_task
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccessIssue]
|
||||
AllMessageValues,
|
||||
|
|
@ -245,7 +249,7 @@ def _provider_file_retrieve_credentials(
|
|||
if llm_router is None or model_id is None:
|
||||
return None
|
||||
try:
|
||||
credentials: Final = llm_router.get_deployment_credentials_with_provider(model_id)
|
||||
credentials: Final = llm_router.registry().credentials(model_id, form="with_provider")
|
||||
except Exception as error:
|
||||
verbose_logger.warning(
|
||||
"Failed to retrieve credentials for provider file "
|
||||
|
|
@ -374,6 +378,12 @@ class _ManagedResourcePrismaClient(Protocol):
|
|||
def db(self) -> _ManagedResourceDatabase: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _WriterRoutedDatabase(Protocol):
|
||||
@property
|
||||
def writer(self) -> _ManagedResourceDatabase: ...
|
||||
|
||||
|
||||
class _SchedulerWithJobLookup(Protocol):
|
||||
def get_job(self, job_id: str) -> object: ...
|
||||
|
||||
|
|
@ -388,6 +398,134 @@ class _RouterFileCallKwargs(TypedDict, total=False):
|
|||
custom_llm_provider: ReadOnly[str | None]
|
||||
|
||||
|
||||
_MODEL_FILE_ID_MAPPING: Final = TypeAdapter(dict[str, str])
|
||||
_FILE_UPLOAD_ONLY_FIELDS: Final = frozenset({"file", "purpose", "expires_after", "extra_body"})
|
||||
_FILE_DELETE_RESERVED_FIELDS: Final = frozenset({"model", "file_id", "_litellm_internal_model_credentials"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderUpload:
|
||||
model_id: str
|
||||
provider_file_id: str
|
||||
upload_call_id: str | None = None
|
||||
|
||||
|
||||
def _provider_uploads(file_object: OpenAIFileObject) -> tuple[_ProviderUpload, ...]:
|
||||
file_hidden_params: Final = cast( # cast-ok: preserve mapping operations on dynamic file metadata
|
||||
dict[str, object], getattr(file_object, HIDDEN_PARAMS_ATTR)
|
||||
)
|
||||
model_file_id_mapping: Final = _MODEL_FILE_ID_MAPPING.validate_python(
|
||||
file_hidden_params.get("model_file_id_mapping") or {}
|
||||
)
|
||||
upload_call_id: Final = file_hidden_params.get("litellm_call_id")
|
||||
return tuple(
|
||||
_ProviderUpload(
|
||||
model_id=model_id,
|
||||
provider_file_id=provider_file_id,
|
||||
upload_call_id=upload_call_id if isinstance(upload_call_id, str) else None,
|
||||
)
|
||||
for model_id, provider_file_id in model_file_id_mapping.items()
|
||||
)
|
||||
|
||||
|
||||
def _keeps_one_upload_per_target(llm_router: Router, model_id: str) -> bool:
|
||||
credentials: Final = _provider_file_retrieve_credentials(llm_router=llm_router, model_id=model_id)
|
||||
return (credentials or {}).get("custom_llm_provider") == LlmProviders.LITELLM_PROXY.value
|
||||
|
||||
|
||||
def _is_kept_upload(uploads: Sequence[_ProviderUpload], index: int, per_target_model_ids: frozenset[str]) -> bool:
|
||||
upload: Final = uploads[index]
|
||||
other_uploads: Final = uploads[index + 1 :] if upload.model_id in per_target_model_ids else uploads[:index]
|
||||
return all(other.model_id != upload.model_id for other in other_uploads)
|
||||
|
||||
|
||||
def _upload_from_trailing_id_fields(decoded_unified_file_id: str) -> _ProviderUpload:
|
||||
trailing_fields: Final = decoded_unified_file_id.rpartition(";llm_output_file_id,")[2]
|
||||
provider_file_id, _, model_id = trailing_fields.partition(";llm_output_file_model_id,")
|
||||
return _ProviderUpload(model_id=model_id, provider_file_id=provider_file_id)
|
||||
|
||||
|
||||
def _carried_upload_missing_from(unified_file_id: str, model_file_ids: Mapping[str, str]) -> _ProviderUpload | None:
|
||||
decoded: Final = _is_base64_encoded_unified_file_id(unified_file_id)
|
||||
carried: Final = _upload_from_trailing_id_fields(decoded) if decoded else None
|
||||
if carried is None or model_file_ids.get(carried.model_id) in (None, carried.provider_file_id):
|
||||
return None
|
||||
return carried
|
||||
|
||||
|
||||
async def _delete_provider_file(
|
||||
llm_router: Router,
|
||||
model_id: str,
|
||||
provider_file_id: str,
|
||||
request_data: Mapping[str, object],
|
||||
) -> None:
|
||||
credentials: Final = llm_router.registry().credentials(model_id, form="with_provider")
|
||||
delete_data: Final = {
|
||||
**{k: v for k, v in request_data.items() if k not in _FILE_DELETE_RESERVED_FIELDS},
|
||||
**(
|
||||
{"_litellm_internal_model_credentials": MappingProxyType(dict(credentials))}
|
||||
if credentials is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
await llm_router.afile_delete(
|
||||
model=model_id,
|
||||
file_id=provider_file_id,
|
||||
**cast(_RouterFileCallKwargs, delete_data),
|
||||
)
|
||||
|
||||
|
||||
def _with_own_nested_dicts(request_data: Mapping[str, object]) -> MappingProxyType[str, object]:
|
||||
return MappingProxyType({k: copy.copy(v) if isinstance(v, dict) else v for k, v in request_data.items()})
|
||||
|
||||
|
||||
async def _delete_redundant_upload(
|
||||
llm_router: Router,
|
||||
upload: _ProviderUpload,
|
||||
delete_request: Mapping[str, object],
|
||||
parent_call_id: str | None,
|
||||
) -> None:
|
||||
try:
|
||||
with internal_sub_call(parent_call_id):
|
||||
await _delete_provider_file(
|
||||
llm_router, upload.model_id, upload.provider_file_id, _with_own_nested_dicts(delete_request)
|
||||
)
|
||||
except Exception as error:
|
||||
verbose_logger.warning(
|
||||
f"Could not delete the redundant upload provider_file_id={sanitize_for_log(upload.provider_file_id)} "
|
||||
f"at deployment model_id={sanitize_for_log(upload.model_id)}: "
|
||||
f"{type(error).__name__} {sanitize_for_log(error)}"
|
||||
)
|
||||
|
||||
|
||||
async def _delete_redundant_uploads(
|
||||
llm_router: Router,
|
||||
uploads: Sequence[_ProviderUpload],
|
||||
delete_request: Mapping[str, object],
|
||||
) -> None:
|
||||
await asyncio.gather(
|
||||
*(_delete_redundant_upload(llm_router, upload, delete_request, upload.upload_call_id) for upload in uploads)
|
||||
)
|
||||
|
||||
|
||||
def _start_deleting_redundant_uploads(
|
||||
llm_router: Router,
|
||||
uploads: Sequence[_ProviderUpload],
|
||||
create_file_request: CreateFileRequest,
|
||||
) -> None:
|
||||
if not uploads:
|
||||
return
|
||||
delete_request: Final = MappingProxyType(
|
||||
{k: v for k, v in create_file_request.items() if k not in _FILE_UPLOAD_ONLY_FIELDS}
|
||||
)
|
||||
with post_response_phase():
|
||||
spawn_logging_task(
|
||||
asyncio.get_running_loop(),
|
||||
_delete_redundant_uploads(llm_router, uploads, delete_request),
|
||||
task_name="managed file repeat upload cleanup",
|
||||
)
|
||||
|
||||
|
||||
def _managed_database(prisma_client: _ManagedResourcePrismaClient) -> _ManagedResourceDatabase:
|
||||
return prisma_client.db
|
||||
|
||||
|
|
@ -479,6 +617,11 @@ def _managed_object_table(prisma_client: _ManagedResourcePrismaClient) -> _Manag
|
|||
return prisma_client.db.litellm_managedobjecttable
|
||||
|
||||
|
||||
def _writer_database(db: _ManagedResourceDatabase) -> _ManagedResourceDatabase:
|
||||
"""Reads that feed a write must see the create that just happened; a lagging read replica may not"""
|
||||
return db.writer if isinstance(db, _WriterRoutedDatabase) else db
|
||||
|
||||
|
||||
_JSON_VALUE_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
||||
_POSTGRES_UNSTORABLE_CHARACTER: Final = "\x00"
|
||||
|
||||
|
|
@ -512,6 +655,25 @@ def _without_model_output(
|
|||
)
|
||||
|
||||
|
||||
def _with_model_id(
|
||||
file_object: LiteLLMBatch | LiteLLMFineTuningJob | ResponsesAPIResponse,
|
||||
model_id: str | None,
|
||||
) -> LiteLLMBatch | LiteLLMFineTuningJob | ResponsesAPIResponse:
|
||||
return file_object if model_id is None else file_object.model_copy(update={"model_id": model_id})
|
||||
|
||||
|
||||
async def _stored_model_id(managed_object_table: _ManagedObjectTableActions, unified_object_id: str) -> str | None:
|
||||
row: Final = await managed_object_table.find_first(where={"unified_object_id": unified_object_id})
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
parsed: Final = _JSON_VALUE_ADAPTER.validate_python(_decode_json_blob(row.file_object))
|
||||
except (json.JSONDecodeError, ValidationError):
|
||||
return None
|
||||
model_id: Final = parsed.get("model_id") if isinstance(parsed, dict) else None
|
||||
return model_id if isinstance(model_id, str) and model_id else None
|
||||
|
||||
|
||||
def _storage_metadata_of(file_object: OpenAIFileObject | None) -> Mapping[str, str]:
|
||||
hidden_params: Final = cast( # cast-ok: _hidden_params is an untyped attribute the upload path sets
|
||||
"Mapping[str, object]", getattr(file_object, "_hidden_params", None) or {}
|
||||
|
|
@ -697,9 +859,17 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
persist_attribution: bool = False,
|
||||
create_if_missing: bool = True,
|
||||
batch_processed: bool = False,
|
||||
creating_model_id: str | None = None,
|
||||
) -> None:
|
||||
"""Persist a managed object row, caching it and upserting it in the DB.
|
||||
|
||||
creating_model_id is the router deployment that created the object. It is kept
|
||||
inside the stored file_object JSON as `model_id`, because the raw provider id an
|
||||
unmanaged batch stores as unified_object_id says nothing about which deployment
|
||||
made it, and the cost poller needs that deployment to read the result back. A
|
||||
later observe of the row (create_if_missing=False) keeps the stored value rather
|
||||
than overwriting it with whichever deployment served the retrieve.
|
||||
|
||||
persist_attribution is set only by the batch and background response creates,
|
||||
the callers that can speak for the creator; it gates the api_key, request_tags, and,
|
||||
for a response, end_user_id columns that the cost poll jobs bill against, so a later poll or
|
||||
|
|
@ -720,7 +890,14 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
It is written only in the upsert create branch.
|
||||
"""
|
||||
verbose_logger.info(f"Storing LiteLLM Managed {file_purpose} object with id={unified_object_id} in cache")
|
||||
stored_object: Final = _without_model_output(file_object)
|
||||
managed_database: Final = _managed_database(self.prisma_client)
|
||||
managed_object_table: Final = managed_database.litellm_managedobjecttable
|
||||
preserved_model_id: Final = (
|
||||
creating_model_id
|
||||
if creating_model_id is not None or create_if_missing
|
||||
else await _stored_model_id(_writer_database(managed_database).litellm_managedobjecttable, unified_object_id)
|
||||
)
|
||||
stored_object: Final = _with_model_id(_without_model_output(file_object), preserved_model_id)
|
||||
litellm_managed_object = LiteLLM_ManagedObjectTable(
|
||||
unified_object_id=unified_object_id,
|
||||
model_object_id=model_object_id,
|
||||
|
|
@ -752,7 +929,6 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
"status": file_object.status,
|
||||
"updated_by": user_api_key_dict.user_id,
|
||||
}
|
||||
managed_object_table: Final = _managed_object_table(self.prisma_client)
|
||||
if not create_if_missing:
|
||||
await managed_object_table.update_many(
|
||||
where={"unified_object_id": unified_object_id},
|
||||
|
|
@ -1989,16 +2165,16 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
target_model_names_list=target_model_names_list,
|
||||
)
|
||||
|
||||
## STORE MODEL MAPPINGS IN DB
|
||||
model_mappings: Dict[str, str] = {}
|
||||
|
||||
for file_object in responses:
|
||||
file_hidden_params = cast( # cast-ok: preserve mapping operations on dynamic file metadata
|
||||
dict[str, object], getattr(file_object, HIDDEN_PARAMS_ATTR)
|
||||
)
|
||||
model_file_id_mapping = file_hidden_params.get("model_file_id_mapping")
|
||||
if model_file_id_mapping and isinstance(model_file_id_mapping, dict):
|
||||
model_mappings.update(model_file_id_mapping)
|
||||
uploads: Final = tuple(chain.from_iterable(map(_provider_uploads, responses)))
|
||||
per_target_model_ids: Final = frozenset(
|
||||
model_id
|
||||
for model_id in {upload.model_id for upload in uploads}
|
||||
if _keeps_one_upload_per_target(llm_router, model_id)
|
||||
)
|
||||
kept_uploads: Final = tuple(
|
||||
upload for index, upload in enumerate(uploads) if _is_kept_upload(uploads, index, per_target_model_ids)
|
||||
)
|
||||
model_mappings: Final = {upload.model_id: upload.provider_file_id for upload in kept_uploads}
|
||||
|
||||
await self.store_unified_file_id(
|
||||
file_id=response.id,
|
||||
|
|
@ -2007,6 +2183,15 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
model_mappings=model_mappings,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
_start_deleting_redundant_uploads(
|
||||
llm_router,
|
||||
tuple(
|
||||
upload
|
||||
for upload in uploads
|
||||
if upload not in kept_uploads and upload.model_id not in per_target_model_ids
|
||||
),
|
||||
create_file_request,
|
||||
)
|
||||
|
||||
# Emit Prometheus metrics for managed file creation
|
||||
prom_logger = self._get_prometheus_logger()
|
||||
|
|
@ -2170,6 +2355,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
request_tags=request_tags_from_metadata(request_metadata if isinstance(request_metadata, dict) else {}),
|
||||
persist_attribution=is_batch_create,
|
||||
create_if_missing=is_batch_create,
|
||||
creating_model_id=model_id if is_batch_create else None,
|
||||
)
|
||||
if not is_batch_create:
|
||||
await self.store_batch_output_file_ownership(
|
||||
|
|
@ -2646,23 +2832,13 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
specific_model_file_id_mapping: Final = model_file_id_mapping.get(file_id)
|
||||
if not specific_model_file_id_mapping:
|
||||
return
|
||||
filtered_data: Final = {
|
||||
k: v for k, v in data.items() if k not in ("model", "file_id", "_litellm_internal_model_credentials")
|
||||
}
|
||||
for model_id, model_file_id in specific_model_file_id_mapping.items():
|
||||
credentials = llm_router.get_deployment_credentials_with_provider(model_id=model_id)
|
||||
delete_data = {
|
||||
**filtered_data,
|
||||
**(
|
||||
{"_litellm_internal_model_credentials": MappingProxyType(dict(credentials))}
|
||||
if credentials is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
await llm_router.afile_delete(
|
||||
model=model_id,
|
||||
file_id=model_file_id,
|
||||
**cast(_RouterFileCallKwargs, delete_data),
|
||||
await _delete_provider_file(llm_router, model_id, model_file_id, data)
|
||||
unrecorded_upload: Final = _carried_upload_missing_from(file_id, specific_model_file_id_mapping)
|
||||
delete_call_id: Final = data.get("litellm_call_id")
|
||||
if unrecorded_upload is not None:
|
||||
await _delete_redundant_upload(
|
||||
llm_router, unrecorded_upload, data, delete_call_id if isinstance(delete_call_id, str) else None
|
||||
)
|
||||
|
||||
async def afile_content(
|
||||
|
|
@ -2694,7 +2870,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
# against the deployment's configured bucket, which they only
|
||||
# trust from this immutable server-side snapshot, never from
|
||||
# request params.
|
||||
credentials = llm_router.get_deployment_credentials_with_provider(model_id=model_id)
|
||||
credentials = llm_router.registry().credentials(model_id, form="with_provider")
|
||||
if credentials is not None:
|
||||
data["_litellm_internal_model_credentials"] = cast(Dict, MappingProxyType(dict(credentials)))
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -290,7 +290,7 @@ def _check_team_project_limits(
|
|||
|
||||
|
||||
def _router_access_group_names(llm_router: "Router | None") -> frozenset[str]:
|
||||
return frozenset(llm_router.get_model_access_groups()) if llm_router is not None else frozenset()
|
||||
return frozenset(llm_router.registry().access_groups()) if llm_router is not None else frozenset()
|
||||
|
||||
|
||||
def _project_models_expanding_at_request_time(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.80"
|
||||
version = "0.1.81"
|
||||
description = "Package for LiteLLM Enterprise features"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.1.80"
|
||||
version = "0.1.81"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
63
examples/bigmodel.md
Normal file
63
examples/bigmodel.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# BigModel (Zhipu AI)
|
||||
|
||||
Use `bigmodel/` for the domestic BigModel platform and `zai/` for international Z.AI. Each platform uses its own credentials and default URL
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: glm
|
||||
litellm_params:
|
||||
model: bigmodel/glm-5.3
|
||||
api_key: os.environ/BIGMODEL_API_KEY
|
||||
api_base: https://open.bigmodel.cn
|
||||
```
|
||||
|
||||
One model configuration supports all three native APIs. The provider selects the upstream endpoint from the requested API
|
||||
|
||||
| Proxy endpoint | BigModel endpoint |
|
||||
| --- | --- |
|
||||
| `/v1/chat/completions` | `/api/paas/v4/chat/completions` |
|
||||
| `/v1/responses` | `/api/v1/responses` |
|
||||
| `/v1/messages` | `/api/anthropic/v1/messages` |
|
||||
|
||||
`api_base` is optional and defaults to `https://open.bigmodel.cn`. Set `BIGMODEL_API_BASE` to override the default, or pass an explicit `api_base` to override the environment value. Existing API-specific base URLs and complete endpoint URLs are normalized, preserving a custom proxy's path prefix
|
||||
|
||||
A GLM Coding Plan key uses `https://open.bigmodel.cn/api/coding/paas/v4` as its `api_base`, since Coding Plan and standard keys do not work on each other's endpoints. Chat Completions then calls the Coding Plan endpoint, and Responses and Messages keep the endpoints in the table above
|
||||
|
||||
The Admin UI lists BigModel (Zhipu AI), displays its official logo, and provides a required password API Key field and an optional API Base field with the default URL. Authentication uses an explicit `api_key` or `BIGMODEL_API_KEY`
|
||||
|
||||
```python
|
||||
import litellm
|
||||
|
||||
chat = litellm.completion(
|
||||
model="bigmodel/glm-5.3",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
thinking={"type": "enabled"},
|
||||
reasoning_effort="low",
|
||||
)
|
||||
|
||||
response = litellm.responses(
|
||||
model="bigmodel/glm-5.3",
|
||||
input="Hello",
|
||||
reasoning={"effort": "low"},
|
||||
store=True,
|
||||
)
|
||||
|
||||
async def messages():
|
||||
return await litellm.anthropic.messages.acreate(
|
||||
model="bigmodel/glm-5.3",
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
thinking={"type": "adaptive"},
|
||||
output_config={"effort": "low"},
|
||||
)
|
||||
```
|
||||
|
||||
Each API retains its native response format and streaming events. The native Messages SDK entry point is asynchronous, as is the proxy's `/v1/messages` route
|
||||
|
||||
GLM-5.3, GLM-5.3-Flash, and GLM-5.3-FlashX use always-on thinking. GLM-5.3 supports `low`, `high`, and `max` reasoning effort. Messages preserves native `thinking`, `output_config`, and `reasoning_effort` parameters
|
||||
|
||||
Set `store=True` when creating a Responses request if you need continuation, retrieval, or deletion. Use the response ID returned by LiteLLM. BigModel documents a seven-day retention period and does not support cancel, compact, native WebSocket, or file_search
|
||||
|
||||
The model catalog and packaged backup include GLM-5.3, GLM-5.3-Flash, and GLM-5.3-FlashX. Prices are expressed in USD/token using a static 7.0 CNY/USD conversion, with the source and verification date in each entry's metadata. Use existing `litellm_params` cost fields for contract-specific deployment prices
|
||||
|
||||
API references: [Chat Completions](https://docs.bigmodel.cn/cn/guide/develop/openai/introduction), [Responses](https://docs.bigmodel.cn/cn/guide/develop/responses/introduction), [Messages](https://docs.bigmodel.cn/cn/guide/develop/claude/introduction), and [pricing](https://docs.bigmodel.cn/cn/guide/start/pricing), checked on 2026-10-27
|
||||
|
|
@ -40,6 +40,7 @@ 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 && \
|
||||
|
|
@ -144,6 +145,7 @@ 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/gateway /app/gateway
|
||||
COPY --from=builder --chown=nonroot:nonroot /app/litellm_cli /app/litellm_cli
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ handed the loopback URL it listens on. A pre-existing ``DATABASE_URL`` wins in
|
|||
wins under token auth too, so exporting it here is enough for every worker to
|
||||
pick the pooled URL up unchanged.
|
||||
|
||||
LiteLLM imports wait for ``main``: every uvicorn worker imports this module again
|
||||
before it can answer its first 5 s health ping, so a slow host would kill each one.
|
||||
Workers then load the gateway app through ``worker_app`` without garbage collection
|
||||
pauses, which hold the GIL long enough to miss a ping.
|
||||
|
||||
Run with:
|
||||
python -m gateway.launch --workers 4 --host 0.0.0.0 --port 4000
|
||||
"""
|
||||
|
|
@ -15,19 +20,19 @@ Run with:
|
|||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from typing import Final
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from uvicorn.main import main as uvicorn_main
|
||||
|
||||
from litellm.proxy.db.db_url_settings import DatabaseURLSettings
|
||||
from litellm.proxy.db.pgbouncer import (
|
||||
PgBouncerError,
|
||||
PgBouncerSettings,
|
||||
export_pooled_database_url,
|
||||
start_in_container_pgbouncer,
|
||||
)
|
||||
from litellm_cli.main import without_gc_pauses
|
||||
|
||||
GATEWAY_APP: Final = "gateway.main:app"
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import FastAPI
|
||||
|
||||
from litellm.proxy.db.db_url_settings import DatabaseURLSettings
|
||||
from litellm.proxy.db.pgbouncer import PgBouncerError, PgBouncerSettings
|
||||
|
||||
GATEWAY_APP: Final = "gateway.launch:worker_app"
|
||||
KEEPALIVE_FLAG: Final = "--timeout-keep-alive"
|
||||
|
||||
|
||||
|
|
@ -35,21 +40,23 @@ def uvicorn_argv(argv: Sequence[str], environ: Mapping[str, str]) -> tuple[str,
|
|||
"""Honor ``KEEPALIVE_TIMEOUT`` like ``proxy_cli.py`` does, unless the flag was passed explicitly."""
|
||||
keepalive: Final = environ.get("KEEPALIVE_TIMEOUT")
|
||||
if keepalive is None or any(arg == KEEPALIVE_FLAG or arg.startswith(f"{KEEPALIVE_FLAG}=") for arg in argv):
|
||||
return (GATEWAY_APP, *argv)
|
||||
return (GATEWAY_APP, *argv, KEEPALIVE_FLAG, keepalive)
|
||||
return (GATEWAY_APP, "--factory", *argv)
|
||||
return (GATEWAY_APP, "--factory", *argv, KEEPALIVE_FLAG, keepalive)
|
||||
|
||||
|
||||
def pool_database_url(
|
||||
settings: DatabaseURLSettings,
|
||||
pgbouncer: PgBouncerSettings,
|
||||
settings: "DatabaseURLSettings",
|
||||
pgbouncer: "PgBouncerSettings",
|
||||
environ: Mapping[str, str],
|
||||
) -> str | PgBouncerError | None:
|
||||
) -> "str | PgBouncerError | None":
|
||||
"""Start the in-container PgBouncer and return its loopback URL, or None when ``pgbouncer.enabled`` is off.
|
||||
|
||||
The upstream URL is whatever ``apply_to_env`` assembled from the discrete
|
||||
``DATABASE_*`` vars (or an operator-pinned ``DATABASE_URL``). Under token
|
||||
auth the pooler mints and renews the upstream token itself.
|
||||
"""
|
||||
from litellm.proxy.db.pgbouncer import PgBouncerError, start_in_container_pgbouncer
|
||||
|
||||
if not pgbouncer.enabled:
|
||||
return None
|
||||
upstream_url: Final = environ.get("DATABASE_URL")
|
||||
|
|
@ -58,11 +65,20 @@ def pool_database_url(
|
|||
return start_in_container_pgbouncer(pgbouncer, upstream_url, token_auth=settings.token_auth())
|
||||
|
||||
|
||||
def worker_app() -> "FastAPI":
|
||||
with without_gc_pauses():
|
||||
from gateway.main import app
|
||||
return app
|
||||
|
||||
|
||||
def _serve(argv: Sequence[str]) -> None:
|
||||
uvicorn_main(tuple(argv), prog_name="uvicorn")
|
||||
|
||||
|
||||
def main(argv: Sequence[str], serve: Callable[[Sequence[str]], None] = _serve) -> None:
|
||||
from litellm.proxy.db.db_url_settings import DatabaseURLSettings
|
||||
from litellm.proxy.db.pgbouncer import PgBouncerError, PgBouncerSettings, export_pooled_database_url
|
||||
|
||||
settings: Final = DatabaseURLSettings.from_env()
|
||||
settings.apply_to_env()
|
||||
pooled_url: Final = pool_database_url(settings, PgBouncerSettings(), os.environ)
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ GATEWAY_EXACT_PATHS: frozenset[str] = frozenset(
|
|||
"/test",
|
||||
"/debug/memory/summary",
|
||||
"/api/event_logging/batch",
|
||||
"/eu_jev/v1/systemone",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,5 @@ dependencies:
|
|||
- name: redis
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
version: 18.19.1
|
||||
- name: lens
|
||||
repository: oci://ghcr.io/berriai/charts
|
||||
version: 0.1.0-dev.0
|
||||
digest: sha256:16b92c3d7fc74632e8aa11e602c136d273c774ed39b2fe970b80273e0570898b
|
||||
generated: '2026-10-09T04:18:10.643228000Z'
|
||||
digest: sha256:38962e231f6596b93f82a8412bbe4cf5de696caecf5775dfbbd163383eb1c009
|
||||
generated: '2026-10-30T02:13:11.505726000Z'
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ type: application
|
|||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 1.1.3
|
||||
version: 1.1.4
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
|
|
@ -39,6 +39,3 @@ dependencies:
|
|||
version: "18.19.1"
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
condition: redis.enabled
|
||||
- name: lens
|
||||
version: 0.1.0-dev.0
|
||||
repository: oci://ghcr.io/berriai/charts
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ If `db.useStackgresOperator` is used (not yet implemented):
|
|||
|
||||
| `billingMetrics.enabled` | Enable enterprise billable-request metering. Requires an enterprise license. | `false` |
|
||||
| `billingMetrics.endpoint` | Collector that the billable-request counter is pushed to. | `https://telemetry.litellm.ai` |
|
||||
| `billingMetrics.secretName` | Name of an existing Secret holding the mTLS client certificate, under the keys `tls.crt` and `tls.key`. | `litellm-billing-metrics-mtls` |
|
||||
| `billingMetrics.caSecretName` | Name of an existing Secret holding a CA bundle under the key `ca.crt`. Only needed for a private or test collector whose server certificate is not on the public web PKI. | `""` |
|
||||
| `billingMetrics.tokenUrl` | Cognito OAuth2 client-credentials token endpoint. | `https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token` |
|
||||
| `billingMetrics.secretName` | Name of an existing Secret with `client-id` and `client-secret` keys. | `litellm-billing-metrics` |
|
||||
| `billingMetrics.exportIntervalMs` | How often the counter is pushed, in milliseconds. The proxy defaults to `60000` when unset. | `""` |
|
||||
|
||||
#### Example `proxy_config` ConfigMap from values (default):
|
||||
|
|
@ -109,10 +109,10 @@ type: Opaque
|
|||
|
||||
#### Enterprise billable-request metering
|
||||
|
||||
Enterprise licenses meter billable requests by pushing a counter to LiteLLM's collector over mutual TLS. The chart does not create the client certificate; it mounts one you already hold, read-only, so the private key is never exposed through the environment. Create the Secret under the name the chart expects, then turn the block on:
|
||||
Enterprise licenses meter billable requests by pushing a counter to LiteLLM's collector with Cognito bearer authentication. Create a Secret with `client-id` and `client-secret` keys, then enable the chart values:
|
||||
|
||||
```
|
||||
kubectl create secret tls litellm-billing-metrics-mtls --cert=client.crt --key=client.key
|
||||
kubectl create secret generic litellm-billing-metrics --from-literal=client-id=... --from-literal=client-secret=...
|
||||
```
|
||||
|
||||
```
|
||||
|
|
@ -120,7 +120,8 @@ billingMetrics:
|
|||
enabled: true
|
||||
```
|
||||
|
||||
Set `billingMetrics.caSecretName` only when the collector is a private or test one whose server certificate is not on the public web PKI; the production collector needs no CA override. The chart fails the render rather than deploying a proxy that silently never exports, so a missing `secretName` or an emptied `endpoint` surfaces at `helm install` time.
|
||||
The endpoint and token URL must use HTTPS. The chart requires both URLs and
|
||||
the Secret name when billing metrics are enabled
|
||||
|
||||
### Database Settings
|
||||
|
||||
|
|
@ -256,6 +257,10 @@ itself.
|
|||
|
||||
## Connect Lens
|
||||
|
||||
`lensWorker.mode` selects `disabled`, `bundled` or `external`. Bundled mode generates separate service and identity-signing credentials. External mode requires `lensWorker.gateway.secretName` and `lensWorker.serviceTokenSecret.name`; their keys default to `gateway-secret` and `service-token`. Provision distinct values matching the external Lens deployment
|
||||
This chart does not deploy Lens. Deploy Lens from [its own chart](https://github.com/BerriAI/lens/tree/main/helm/lens), then set `lens.enabled: true`, `lens.url`, `lens.publicUrl`, `lens.gatewaySecret.name` and `lens.serviceTokenSecret.name`. The secret keys default to `gateway-secret` and `service-token`. Set `lens.service.name` when that Lens runs in this release's namespace and the chart ingress should route `/lens-ingest` to it. The old `lensWorker` values are no longer read, and enabling them fails the render
|
||||
|
||||
Follow the [Lens Helm connection guide](https://github.com/BerriAI/lens/blob/main/helm/lens/README.md#connect-a-gateway) for complete values, verification, GitOps credential requirements and existing-data migration. Source-chart installation requires a built Lens image until the first signed Lens release is published
|
||||
## Shutdown accounting
|
||||
|
||||
`GRACEFUL_SHUTDOWN_TIMEOUT` defaults to 30 seconds. Pending success callbacks share the time remaining since SIGTERM or the first `/health/drain` call. Callback flushing reserves the existing 10-second teardown headroom for cancellation, tracked accounting tasks and persistence. Exhausting this budget can discard callbacks, which is logged
|
||||
|
||||
Set `terminationGracePeriodSeconds` to at least `GRACEFUL_SHUTDOWN_TIMEOUT + 10`, allowing additional time for preStop hooks and database writes. This is not a hard shutdown bound or a guarantee of persistence. Database retries retain their own limits, and cancellation-resistant callbacks can remain alive. The existing preStop hook can also be followed by Uvicorn's separate connection drain, as described in `values.yaml`
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -50,53 +50,27 @@ app.kubernetes.io/name: {{ include "litellm.name" . }}
|
|||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Enterprise billable-request metering. The client certificate identifies the
|
||||
deployment to LiteLLM's collector, so it is mounted read-only from an existing
|
||||
Secret rather than passed through the environment.
|
||||
*/}}
|
||||
{{- define "litellm.billingMetrics.certDir" -}}/etc/litellm/billing-mtls{{- end -}}
|
||||
{{- define "litellm.billingMetrics.caDir" -}}/etc/litellm/billing-mtls-ca{{- end -}}
|
||||
|
||||
{{- define "litellm.billingMetricsEnv" -}}
|
||||
- name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: {{ required "billingMetrics.endpoint is required when billingMetrics.enabled is true" .Values.billingMetrics.endpoint | quote }}
|
||||
- name: LITELLM_BILLING_METRICS_CLIENT_CERT
|
||||
value: {{ printf "%s/tls.crt" (include "litellm.billingMetrics.certDir" .) | quote }}
|
||||
- name: LITELLM_BILLING_METRICS_CLIENT_KEY
|
||||
value: {{ printf "%s/tls.key" (include "litellm.billingMetrics.certDir" .) | quote }}
|
||||
{{- if .Values.billingMetrics.caSecretName }}
|
||||
- name: LITELLM_BILLING_METRICS_CA_CERT
|
||||
value: {{ printf "%s/ca.crt" (include "litellm.billingMetrics.caDir" .) | quote }}
|
||||
{{- end }}
|
||||
- name: LITELLM_BILLING_METRICS_TOKEN_URL
|
||||
value: {{ required "billingMetrics.tokenUrl is required when billingMetrics.enabled is true" .Values.billingMetrics.tokenUrl | quote }}
|
||||
- name: LITELLM_BILLING_METRICS_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true" .Values.billingMetrics.secretName }}
|
||||
key: client-id
|
||||
- name: LITELLM_BILLING_METRICS_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true" .Values.billingMetrics.secretName }}
|
||||
key: client-secret
|
||||
{{- with .Values.billingMetrics.exportIntervalMs }}
|
||||
- name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.billingMetricsVolumes" -}}
|
||||
- name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key)" .Values.billingMetrics.secretName }}
|
||||
{{- if .Values.billingMetrics.caSecretName }}
|
||||
- name: billing-metrics-mtls-ca
|
||||
secret:
|
||||
secretName: {{ .Values.billingMetrics.caSecretName }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.billingMetricsVolumeMounts" -}}
|
||||
- name: billing-metrics-mtls
|
||||
mountPath: {{ include "litellm.billingMetrics.certDir" . }}
|
||||
readOnly: true
|
||||
{{- if .Values.billingMetrics.caSecretName }}
|
||||
- name: billing-metrics-mtls-ca
|
||||
mountPath: {{ include "litellm.billingMetrics.caDir" . }}
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
|
|
@ -372,10 +346,6 @@ through an emptyDir. Empty when the sidecar is off or uses 127.0.0.1 TCP.
|
|||
value: {{ .Values.collector.drainTimeoutSeconds | quote }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.image" -}}
|
||||
{{- include "lens.image" (dict "Values" .Values.lensWorker "Chart" .Subcharts.lens.Chart) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.gateway.collectorSocketDir" -}}
|
||||
{{- if and .Values.gateway.collector.enabled (hasPrefix "unix://" .Values.gateway.collector.address) -}}
|
||||
{{- dir (trimPrefix "unix://" .Values.gateway.collector.address) -}}
|
||||
|
|
@ -402,95 +372,28 @@ shutdown drain window.
|
|||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.labels" -}}
|
||||
{{- $labels := include "litellm.labels" . | fromYaml -}}
|
||||
{{- $_ := set $labels "app.kubernetes.io/name" (printf "%s-lens-worker" (include "litellm.name" . | trunc 51 | trimSuffix "-")) -}}
|
||||
{{- toYaml $labels -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.serviceTokenSecretName" -}}
|
||||
{{- .Values.lensWorker.serviceTokenSecret.name | default (printf "%s-lens-service" (include "litellm.fullname" .)) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.gatewaySecretName" -}}
|
||||
{{- .Values.lensWorker.gateway.secretName | default (printf "%s-lens-gateway" (include "litellm.fullname" . | trunc 50 | trimSuffix "-")) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.bundledClickhouse" -}}
|
||||
{{- if and (eq (include "litellm.lens.mode" .) "bundled") .Values.lensWorker.clickhouse.enabled (not .Values.lensWorker.clickhouseSecret.name) -}}true{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.publicUrl" -}}
|
||||
{{- if .Values.lensWorker.publicUrl -}}
|
||||
{{- .Values.lensWorker.publicUrl -}}
|
||||
{{- else if eq (include "litellm.lens.mode" .) "external" -}}
|
||||
{{- fail "lensWorker.publicUrl is required for external Lens" -}}
|
||||
{{- else if .Values.lensWorker.ingress.enabled -}}
|
||||
{{- $tls := or (not (empty .Values.lensWorker.ingress.tls)) (hasKey .Values.lensWorker.ingress.annotations "alb.ingress.kubernetes.io/certificate-arn") -}}
|
||||
{{- printf "%s://%s" (ternary "https" "http" $tls) (required "lensWorker.ingress.host is required" .Values.lensWorker.ingress.host) -}}
|
||||
{{- else if and .Values.ingress.enabled (eq (len .Values.ingress.hosts) 1) -}}
|
||||
{{- $host := required "ingress.hosts[0].host is required" (first .Values.ingress.hosts).host -}}
|
||||
{{- $tls := or (not (empty .Values.ingress.tls)) (hasKey .Values.ingress.annotations "alb.ingress.kubernetes.io/certificate-arn") -}}
|
||||
{{- printf "%s://%s/lens-ingest" (ternary "https" "http" $tls) $host -}}
|
||||
{{- else -}}
|
||||
{{- fail "lensWorker.publicUrl is required when there is no single ingress hostname" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.clickhouseName" -}}
|
||||
{{- printf "%s-lens-clickhouse" (include "litellm.fullname" . | trunc 47 | trimSuffix "-") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensConnectionEnv" -}}
|
||||
{{- $mode := include "litellm.lens.mode" . -}}
|
||||
{{- if ne $mode "disabled" }}
|
||||
{{- if and (eq $mode "external") (not .Values.lensWorker.serviceTokenSecret.name) -}}
|
||||
{{- fail "lensWorker.serviceTokenSecret.name is required for external Lens" -}}
|
||||
{{- with .Values.lensWorker }}
|
||||
{{- if or .enabled (and .mode (ne .mode "disabled")) }}
|
||||
{{- fail "lensWorker was removed; deploy Lens from its own chart and set lens.* instead, see the chart README" }}
|
||||
{{- end }}
|
||||
{{- if and (eq $mode "external") (not .Values.lensWorker.gateway.secretName) -}}
|
||||
{{- fail "lensWorker.gateway.secretName is required for external Lens" -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- with .Values.lens }}
|
||||
{{- if .enabled }}
|
||||
- name: LITELLM_LENS_URL
|
||||
value: {{ if eq $mode "external" }}{{ required "lensWorker.externalUrl is required for external Lens" .Values.lensWorker.externalUrl | quote }}{{ else }}{{ printf "http://%s-lens-worker:%v" (include "litellm.fullname" .) .Values.lensWorker.service.port | quote }}{{ end }}
|
||||
value: {{ required "lens.url is required when lens.enabled is true" .url | quote }}
|
||||
- name: LITELLM_LENS_PUBLIC_URL
|
||||
value: {{ include "litellm.lensWorker.publicUrl" . | quote }}
|
||||
value: {{ required "lens.publicUrl is required when lens.enabled is true" .publicUrl | quote }}
|
||||
- name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "litellm.lensWorker.gatewaySecretName" . | quote }}
|
||||
key: {{ .Values.lensWorker.gateway.secretKey | quote }}
|
||||
name: {{ required "lens.gatewaySecret.name is required when lens.enabled is true" .gatewaySecret.name | quote }}
|
||||
key: {{ .gatewaySecret.key | quote }}
|
||||
- name: LITELLM_LENS_SERVICE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "litellm.lensWorker.serviceTokenSecretName" . | quote }}
|
||||
key: {{ .Values.lensWorker.serviceTokenSecret.key | quote }}
|
||||
name: {{ required "lens.serviceTokenSecret.name is required when lens.enabled is true" .serviceTokenSecret.name | quote }}
|
||||
key: {{ .serviceTokenSecret.key | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lens.mode" -}}
|
||||
{{- $mode := .Values.lensWorker.mode | default (ternary "bundled" "disabled" .Values.lensWorker.enabled) -}}
|
||||
{{- if not (has $mode (list "bundled" "external" "disabled")) -}}
|
||||
{{- fail "lensWorker.mode must be bundled, external or disabled" -}}
|
||||
{{- end -}}
|
||||
{{- $mode -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lens.render" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $values := mergeOverwrite (deepCopy $root.Subcharts.lens.Values) (deepCopy $root.Values.lensWorker) -}}
|
||||
{{- $_ := set $values "fullnameOverride" (printf "%s-lens-worker" (include "litellm.fullname" $root)) -}}
|
||||
{{- $_ := set $values "component" "lens-worker" -}}
|
||||
{{- $_ := set $values "nameOverride" (printf "%s-lens-worker" (include "litellm.name" $root | trunc 51 | trimSuffix "-")) -}}
|
||||
{{- $_ := set $values "imagePullSecrets" $root.Values.imagePullSecrets -}}
|
||||
{{- $_ := set $values.gateway "enabled" true -}}
|
||||
{{- $_ := set $values.gateway "generatedName" (include "litellm.lensWorker.gatewaySecretName" $root) -}}
|
||||
{{- $_ := set $values.clickhouse "nameOverride" (include "litellm.lensWorker.clickhouseName" $root) -}}
|
||||
{{- $_ := set $values.serviceTokenSecret "generatedName" (include "litellm.lensWorker.serviceTokenSecretName" $root) -}}
|
||||
{{- $_ := set $values "publicUrl" ($root.Values.lensWorker.standaloneUrl | default $root.Subcharts.lens.Values.publicUrl) -}}
|
||||
{{- if and (eq .resource "deployment") (or $root.Values.lensWorker.publicUrl $root.Values.lensWorker.ingress.enabled $root.Values.ingress.enabled) -}}
|
||||
{{- $ingestion := include "litellm.lensWorker.publicUrl" $root -}}
|
||||
{{- $_ := set $values "ingestionUrl" $ingestion -}}
|
||||
{{- $_ := set $values "publicUrl" ($root.Values.lensWorker.standaloneUrl | default (trimSuffix "/lens-ingest" $ingestion)) -}}
|
||||
{{- end -}}
|
||||
{{- include (printf "lens.%s" .resource) (dict "Values" $values "Release" $root.Release "Chart" $root.Subcharts.lens.Chart "Capabilities" $root.Capabilities) -}}
|
||||
{{- end -}}
|
||||
|
|
|
|||
|
|
@ -148,9 +148,6 @@ spec:
|
|||
- name: npm
|
||||
mountPath: /.npm
|
||||
{{- end }}
|
||||
{{- if .Values.billingMetrics.enabled }}
|
||||
{{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if include "litellm.collector.socketDir" . }}
|
||||
- name: collector-socket
|
||||
mountPath: {{ include "litellm.collector.socketDir" . }}
|
||||
|
|
@ -234,9 +231,6 @@ spec:
|
|||
items:
|
||||
- key: {{ .Values.proxyConfigMap.key | default "config.yaml" }}
|
||||
path: "config.yaml"
|
||||
{{- if .Values.billingMetrics.enabled }}
|
||||
{{- include "litellm.billingMetricsVolumes" . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if include "litellm.collector.socketDir" . }}
|
||||
- name: collector-socket
|
||||
emptyDir:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "litellm.fullname" . -}}
|
||||
{{- $svcPort := .Values.service.port -}}
|
||||
{{- $lensMode := include "litellm.lens.mode" . -}}
|
||||
{{- $lensService := .Values.lensWorker.externalServiceName | default (printf "%s-lens-worker" $fullName) -}}
|
||||
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
|
||||
|
|
@ -46,18 +44,18 @@ spec:
|
|||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- if or (eq $lensMode "bundled") (and (eq $lensMode "external") $.Values.lensWorker.externalServiceName) }}
|
||||
{{- if and $.Values.lens.enabled $.Values.lens.service.name }}
|
||||
- path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ if eq $lensMode "external" }}{{ $lensService }}{{ else }}{{ $fullName }}-lens-worker{{ end }}
|
||||
name: {{ $.Values.lens.service.name }}
|
||||
port:
|
||||
number: {{ $.Values.lensWorker.service.port }}
|
||||
number: {{ $.Values.lens.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ if eq $lensMode "external" }}{{ $lensService }}{{ else }}{{ $fullName }}-lens-worker{{ end }}
|
||||
servicePort: {{ $.Values.lensWorker.service.port }}
|
||||
serviceName: {{ $.Values.lens.service.name }}
|
||||
servicePort: {{ $.Values.lens.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range .paths }}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
{{- if eq (include "litellm.lens.mode" .) "bundled" }}
|
||||
{{ include "litellm.lens.render" (dict "root" . "resource" "clickhouse") }}
|
||||
{{- end }}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{{- if eq (include "litellm.lens.mode" .) "bundled" }}
|
||||
{{ include "litellm.lens.render" (dict "root" . "resource" "deployment") }}
|
||||
{{- end }}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{{- if eq (include "litellm.lens.mode" .) "bundled" }}
|
||||
{{ include "litellm.lens.render" (dict "root" . "resource" "ingress") }}
|
||||
{{- end }}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{{- if eq (include "litellm.lens.mode" .) "bundled" }}
|
||||
{{ include "litellm.lens.render" (dict "root" . "resource" "secrets") }}
|
||||
{{- end }}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{{- if eq (include "litellm.lens.mode" .) "bundled" }}
|
||||
{{ include "litellm.lens.render" (dict "root" . "resource" "service") }}
|
||||
{{- end }}
|
||||
|
|
@ -1,31 +1,21 @@
|
|||
suite: test billingMetrics wiring on the proxy deployment
|
||||
suite: test bearer-only billingMetrics wiring
|
||||
templates:
|
||||
- deployment.yaml
|
||||
- configmap-litellm.yaml
|
||||
- migrations-job.yaml
|
||||
tests:
|
||||
- it: is off by default, adding no env, volume, or mount
|
||||
- it: is disabled by default
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: litellm-billing-metrics-mtls
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
mountPath: /etc/litellm/billing-mtls
|
||||
readOnly: true
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: https://telemetry.litellm.ai
|
||||
- notContains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics
|
||||
|
||||
- it: renders the endpoint and the mounted cert paths when enabled
|
||||
- it: renders required URLs and Cognito secret references when enabled
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
|
|
@ -39,119 +29,34 @@ tests:
|
|||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_CLIENT_CERT
|
||||
value: /etc/litellm/billing-mtls/tls.crt
|
||||
name: LITELLM_BILLING_METRICS_TOKEN_URL
|
||||
value: https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_CLIENT_KEY
|
||||
value: /etc/litellm/billing-mtls/tls.key
|
||||
|
||||
# The conventional Secret name is the default, so enabling the block is enough.
|
||||
- it: mounts the default cert secret read-only alongside the config volume
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: litellm-billing-metrics-mtls
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
mountPath: /etc/litellm/billing-mtls
|
||||
readOnly: true
|
||||
|
||||
- it: honours a secretName override
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: my-billing-mtls
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: my-billing-mtls
|
||||
- notContains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: litellm-billing-metrics-mtls
|
||||
|
||||
- it: honours an endpoint override
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
endpoint: https://collector.internal:4318
|
||||
asserts:
|
||||
name: LITELLM_BILLING_METRICS_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: litellm-billing-metrics
|
||||
key: client-id
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: https://collector.internal:4318
|
||||
|
||||
# The production collector presents a public web-PKI certificate, so the CA
|
||||
# override must stay absent unless a private collector is configured.
|
||||
- it: omits the CA env, volume, and mount when no caSecretName is set
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
asserts:
|
||||
name: LITELLM_BILLING_METRICS_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: litellm-billing-metrics
|
||||
key: client-secret
|
||||
- notContains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls-ca
|
||||
secret:
|
||||
secretName: billing-ca
|
||||
name: billing-metrics
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: billing-metrics-mtls-ca
|
||||
mountPath: /etc/litellm/billing-mtls-ca
|
||||
readOnly: true
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_CA_CERT
|
||||
value: /etc/litellm/billing-mtls-ca/ca.crt
|
||||
name: billing-metrics
|
||||
|
||||
- it: mounts the CA secret when caSecretName is set
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
caSecretName: billing-ca
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_CA_CERT
|
||||
value: /etc/litellm/billing-mtls-ca/ca.crt
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls-ca
|
||||
secret:
|
||||
secretName: billing-ca
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: billing-metrics-mtls-ca
|
||||
mountPath: /etc/litellm/billing-mtls-ca
|
||||
readOnly: true
|
||||
|
||||
- it: passes the export interval through only when set
|
||||
- it: renders the export interval only when set
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
|
|
@ -164,129 +69,7 @@ tests:
|
|||
name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS
|
||||
value: "5000"
|
||||
|
||||
- it: omits the export interval when unset
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS
|
||||
value: "60000"
|
||||
|
||||
# Kubernetes resolves duplicate env names last-wins, so the chart-owned billing
|
||||
# entries must render after .Values.envVars or a user could silently redirect
|
||||
# the metering export. The three billing entries are the last ones emitted here
|
||||
# (migrationJob, which appends DISABLE_SCHEMA_UPDATE, is off for this case).
|
||||
- it: renders the billing endpoint after envVars so it cannot be shadowed
|
||||
template: deployment.yaml
|
||||
set:
|
||||
migrationJob:
|
||||
enabled: false
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
envVars:
|
||||
LITELLM_BILLING_METRICS_ENDPOINT: https://shadowed.example
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: https://shadowed.example
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].env[-3]
|
||||
value:
|
||||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: https://telemetry.litellm.ai
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].env[-2].name
|
||||
value: LITELLM_BILLING_METRICS_CLIENT_CERT
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].env[-1].name
|
||||
value: LITELLM_BILLING_METRICS_CLIENT_KEY
|
||||
|
||||
- it: keeps user-supplied volumes and mounts alongside the billing secret
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
volumes:
|
||||
- name: custom-callbacks
|
||||
configMap:
|
||||
name: my-callbacks
|
||||
volumeMounts:
|
||||
- name: custom-callbacks
|
||||
mountPath: /app/callbacks
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: custom-callbacks
|
||||
configMap:
|
||||
name: my-callbacks
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: litellm-billing-metrics-mtls
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: custom-callbacks
|
||||
mountPath: /app/callbacks
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
mountPath: /etc/litellm/billing-mtls
|
||||
readOnly: true
|
||||
|
||||
- it: still mounts the proxy config when enabled
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: litellm-config
|
||||
mountPath: /etc/litellm/config.yaml
|
||||
subPath: config.yaml
|
||||
|
||||
# Only the proxy serves billable traffic. The migrations Job must never mount
|
||||
# the client certificate, and it renders its own env and volumes, so nothing
|
||||
# stops a future edit from wiring the billing include into it by mistake.
|
||||
- it: does not touch the migrations job when enabled
|
||||
template: migrations-job.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: https://telemetry.litellm.ai
|
||||
- notExists:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
- notExists:
|
||||
path: spec.template.spec.volumes
|
||||
|
||||
- it: fails loudly when enabled with an emptied secretName
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: ""
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key)
|
||||
|
||||
- it: fails loudly when enabled without an endpoint
|
||||
- it: requires the endpoint when enabled
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
|
|
@ -295,3 +78,23 @@ tests:
|
|||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: billingMetrics.endpoint is required when billingMetrics.enabled is true
|
||||
|
||||
- it: requires the token URL when enabled
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
tokenUrl: ""
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: billingMetrics.tokenUrl is required when billingMetrics.enabled is true
|
||||
|
||||
- it: requires the secret name when enabled
|
||||
template: deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: ""
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ tests:
|
|||
metricsServer.port: 9090
|
||||
billingMetrics.enabled: true
|
||||
billingMetrics.endpoint: https://metering.example.com
|
||||
billingMetrics.secretName: billing-mtls
|
||||
billingMetrics.secretName: billing-metrics
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
|
|
@ -214,9 +214,9 @@ tests:
|
|||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
any: true
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[1].volumeMounts
|
||||
path: spec.template.spec.containers[1].env
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
name: LITELLM_BILLING_METRICS_CLIENT_SECRET
|
||||
any: true
|
||||
|
||||
- it: should give the sidecar the same scratch mounts as the proxy on a read-only root
|
||||
|
|
|
|||
128
helm/litellm-helm/tests/lens_connection_tests.yaml
Normal file
128
helm/litellm-helm/tests/lens_connection_tests.yaml
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
suite: lens connection
|
||||
templates:
|
||||
- deployment.yaml
|
||||
- configmap-litellm.yaml
|
||||
- ingress.yaml
|
||||
tests:
|
||||
- it: connects the proxy to an external Lens
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
value: http://lens.lens.svc:4318
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://lens.example.com/lens-ingest
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-gateway
|
||||
key: gateway-secret
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_SERVICE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-service
|
||||
key: service-token
|
||||
|
||||
- it: requires lens.url when Lens is enabled
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lens.url is required when lens.enabled is true
|
||||
|
||||
- it: requires lens.publicUrl when Lens is enabled
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lens.publicUrl is required when lens.enabled is true
|
||||
|
||||
- it: requires lens.gatewaySecret.name when Lens is enabled
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lens.gatewaySecret.name is required when lens.enabled is true
|
||||
|
||||
- it: requires lens.serviceTokenSecret.name when Lens is enabled
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lens.serviceTokenSecret.name is required when lens.enabled is true
|
||||
|
||||
- it: routes /lens-ingest to the named Lens service ahead of the host paths
|
||||
template: ingress.yaml
|
||||
set:
|
||||
ingress.enabled: true
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
lens.service.name: existing-lens
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.rules[0].http.paths[0].path
|
||||
value: /lens-ingest
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: existing-lens
|
||||
port:
|
||||
number: 4318
|
||||
|
||||
- it: leaves /lens-ingest off the ingress without a Lens service name
|
||||
template: ingress.yaml
|
||||
set:
|
||||
ingress.enabled: true
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- exists:
|
||||
path: spec.rules[0].http.paths
|
||||
- notContains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
any: true
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
suite: Lens endpoint defaults
|
||||
templates:
|
||||
- deployment.yaml
|
||||
- configmap-litellm.yaml
|
||||
set:
|
||||
lensWorker.enabled: true
|
||||
ingress.enabled: true
|
||||
ingress.hosts: [{host: gateway.example, paths: [{path: /, pathType: Prefix}]}]
|
||||
ingress.tls:
|
||||
- hosts: [gateway.example]
|
||||
secretName: tls
|
||||
tests:
|
||||
- it: derives the trace endpoint from the deployment hostname and TLS
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://gateway.example/lens-ingest
|
||||
- it: preserves an explicitly configured public address
|
||||
set:
|
||||
lensWorker.publicUrl: https://custom.example/prefix
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://custom.example/prefix
|
||||
- it: uses the dedicated Lens ingress when configured
|
||||
set:
|
||||
lensWorker.ingress.enabled: true
|
||||
lensWorker.ingress.host: traces.example
|
||||
lensWorker.ingress.tls:
|
||||
- hosts: [traces.example]
|
||||
secretName: traces-tls
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://traces.example
|
||||
- it: uses HTTPS for a dedicated ALB ingress with certificate annotations
|
||||
set:
|
||||
lensWorker.ingress.enabled: true
|
||||
lensWorker.ingress.host: traces.example
|
||||
lensWorker.ingress.className: alb
|
||||
lensWorker.ingress.tls: []
|
||||
lensWorker.ingress.annotations:
|
||||
alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:123456789012:certificate/test
|
||||
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]'
|
||||
alb.ingress.kubernetes.io/ssl-redirect: "443"
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://traces.example
|
||||
- it: uses HTTP for a dedicated ingress without its own TLS
|
||||
set:
|
||||
lensWorker.ingress.enabled: true
|
||||
lensWorker.ingress.host: traces.example
|
||||
lensWorker.ingress.tls: []
|
||||
lensWorker.ingress.annotations: {}
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: http://traces.example
|
||||
- it: uses HTTP when ingress has no TLS
|
||||
set:
|
||||
ingress.tls: []
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: http://gateway.example/lens-ingest
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
---
|
||||
suite: Lens deployment modes
|
||||
templates:
|
||||
- ingress.yaml
|
||||
- configmap-litellm.yaml
|
||||
- deployment.yaml
|
||||
- lens/deployment.yaml
|
||||
- lens/service.yaml
|
||||
- lens/clickhouse.yaml
|
||||
- lens/secrets.yaml
|
||||
set:
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
tests:
|
||||
- it: retains the existing deployment before transferring its release ownership
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: bundled
|
||||
lensWorker.retainResources: true
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.annotations["helm.sh/resource-policy"]
|
||||
value: keep
|
||||
- it: connects an external deployment without installing Lens resources
|
||||
templates:
|
||||
- lens/deployment.yaml
|
||||
- lens/service.yaml
|
||||
- lens/clickhouse.yaml
|
||||
- lens/secrets.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://lens.other-namespace:4318
|
||||
lensWorker.serviceTokenSecret.name: external-lens-signing
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
- it: sends delegated requests to the explicitly selected external Lens
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://lens.other-namespace:4318
|
||||
lensWorker.serviceTokenSecret.name: external-lens-signing
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
value: http://lens.other-namespace:4318
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: external-lens-signing
|
||||
key: gateway-secret
|
||||
- it: disables Lens explicitly even with a retained legacy enable flag
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: disabled
|
||||
lensWorker.enabled: true
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
- it: bundles Lens through explicit mode selection
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: bundled
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 1
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_MODE
|
||||
value: standalone
|
||||
- it: uses separate credentials for external identity and service access
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.serviceTokenSecret: {name: worker-service, key: token}
|
||||
lensWorker.gateway: {secretName: delegated-identity, secretKey: signature}
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: {name: delegated-identity, key: signature}
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_SERVICE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef: {name: worker-service, key: token}
|
||||
- it: supplies the same separate signing secret to the bundled service
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: bundled
|
||||
lensWorker.gateway: {secretName: delegated-identity, secretKey: signature}
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: {name: delegated-identity, key: signature}
|
||||
- it: routes explicit bundled mode with the enable flag off
|
||||
template: ingress.yaml
|
||||
set:
|
||||
fullnameOverride: gateway
|
||||
ingress.enabled: true
|
||||
ingress.hosts: [{host: gateway.example, paths: [{path: /, pathType: Prefix}]}]
|
||||
lensWorker.mode: bundled
|
||||
lensWorker.enabled: false
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.externalServiceName: ""
|
||||
lensWorker.serviceTokenSecret.name: worker-service
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gateway-lens-worker
|
||||
port: {number: 4318}
|
||||
- it: routes external mode to its selected service
|
||||
template: ingress.yaml
|
||||
set:
|
||||
fullnameOverride: gateway
|
||||
ingress.enabled: true
|
||||
ingress.hosts: [{host: gateway.example, paths: [{path: /, pathType: Prefix}]}]
|
||||
lensWorker.mode: external
|
||||
lensWorker.enabled: true
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.externalServiceName: "existing-lens"
|
||||
lensWorker.serviceTokenSecret.name: worker-service
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: existing-lens
|
||||
port: {number: 4318}
|
||||
- it: omits ingestion when an external service has no ingress backend
|
||||
template: ingress.yaml
|
||||
set:
|
||||
fullnameOverride: gateway
|
||||
ingress.enabled: true
|
||||
ingress.hosts: [{host: gateway.example, paths: [{path: /, pathType: Prefix}]}]
|
||||
lensWorker.mode: external
|
||||
lensWorker.enabled: true
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.externalServiceName: ""
|
||||
lensWorker.serviceTokenSecret.name: worker-service
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.rules[0].http.paths
|
||||
content: {path: /lens-ingest}
|
||||
any: true
|
||||
- it: omits ingestion for disabled mode with the enable flag on
|
||||
template: ingress.yaml
|
||||
set:
|
||||
fullnameOverride: gateway
|
||||
ingress.enabled: true
|
||||
ingress.hosts: [{host: gateway.example, paths: [{path: /, pathType: Prefix}]}]
|
||||
lensWorker.mode: disabled
|
||||
lensWorker.enabled: true
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.externalServiceName: ""
|
||||
lensWorker.serviceTokenSecret.name: worker-service
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.rules[0].http.paths
|
||||
content: {path: /lens-ingest}
|
||||
any: true
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
---
|
||||
suite: Lens deployment mode validation
|
||||
templates:
|
||||
- configmap-litellm.yaml
|
||||
- deployment.yaml
|
||||
set:
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
tests:
|
||||
- it: requires a separate identity signing reference for external Lens
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://lens.other-namespace:4318
|
||||
lensWorker.serviceTokenSecret.name: worker-service
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.gateway.secretName is required for external Lens
|
||||
- it: requires an external service address
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.serviceTokenSecret.name: external-lens-signing
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.externalUrl is required for external Lens
|
||||
- it: requires the shared server credential for an external service
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://lens.other-namespace:4318
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.serviceTokenSecret.name is required for external Lens
|
||||
- it: requires an agent reachable URL for external ingestion
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://lens.other-namespace:4318
|
||||
lensWorker.serviceTokenSecret.name: external-lens-signing
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
lensWorker.publicUrl: ''
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.publicUrl is required for external Lens
|
||||
- it: rejects unsupported deployment modes
|
||||
template: deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: typo
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.mode must be bundled, external or disabled
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
suite: Lens preserves credentials across Helm upgrades
|
||||
templates:
|
||||
- lens/secrets.yaml
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
release:
|
||||
namespace: lens
|
||||
name: lens-test
|
||||
upgrade: true
|
||||
kubernetesProvider:
|
||||
scheme:
|
||||
v1/Secret:
|
||||
gvr:
|
||||
version: v1
|
||||
resource: secrets
|
||||
namespaced: true
|
||||
objects:
|
||||
- apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: lens-test-lens-gateway
|
||||
namespace: lens
|
||||
data:
|
||||
gateway-secret: c2F2ZWQtc2lnbmluZy1rZXk=
|
||||
- apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: lens-test-lens-service
|
||||
namespace: lens
|
||||
data:
|
||||
service-token: c2F2ZWQtc2VydmljZS10b2tlbg==
|
||||
- apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: lens-test-lens-clickhouse
|
||||
namespace: lens
|
||||
data:
|
||||
password: c2F2ZWQtZGF0YWJhc2UtcGFzc3dvcmQ=
|
||||
tests:
|
||||
- it: preserves the separate signing credential across upgrades
|
||||
documentSelector: {path: metadata.name, value: lens-test-lens-gateway}
|
||||
asserts:
|
||||
- equal:
|
||||
path: data.gateway-secret
|
||||
value: c2F2ZWQtc2lnbmluZy1rZXk=
|
||||
- notExists: {path: data.service-token}
|
||||
- it: reuses the service credential instead of breaking running services
|
||||
documentIndex: 0
|
||||
asserts:
|
||||
- equal:
|
||||
path: data.service-token
|
||||
value: c2F2ZWQtc2VydmljZS10b2tlbg==
|
||||
- it: reuses the database password instead of locking out stored traces
|
||||
documentIndex: 1
|
||||
asserts:
|
||||
- equal:
|
||||
path: data.password
|
||||
value: c2F2ZWQtZGF0YWJhc2UtcGFzc3dvcmQ=
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
suite: Lens service isolation and ingestion routing
|
||||
templates:
|
||||
- configmap-litellm.yaml
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
- lens/ingress.yaml
|
||||
- lens/service.yaml
|
||||
- lens/deployment.yaml
|
||||
tests:
|
||||
- it: connects deployment.yaml to the shared Lens service
|
||||
template: deployment.yaml
|
||||
set: &id001
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://gateway.example/lens-ingest
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
value: http://lens-test-lens-worker:4318
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://gateway.example/lens-ingest
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-test-lens-gateway
|
||||
key: gateway-secret
|
||||
- it: routes uploads directly to Lens instead of the gateway
|
||||
template: ingress.yaml
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://gateway.example/lens-ingest
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
ingress.enabled: true
|
||||
ingress.hosts:
|
||||
- host: gateway.example
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: lens-test-lens-worker
|
||||
port:
|
||||
number: 4318
|
||||
- it: keeps internal routes out of a dedicated ingestion hostname
|
||||
template: lens/ingress.yaml
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://gateway.example/lens-ingest
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
lensWorker.ingress.enabled: true
|
||||
lensWorker.ingress.host: traces.example
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.rules[0].http.paths
|
||||
value:
|
||||
- path: /v1/
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: lens-test-lens-worker
|
||||
port:
|
||||
name: otlp
|
||||
- it: maps the Lens service to the ingestion listener
|
||||
template: lens/service.yaml
|
||||
set: *id001
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.selector
|
||||
value:
|
||||
app.kubernetes.io/instance: RELEASE-NAME
|
||||
app.kubernetes.io/component: lens-worker
|
||||
- equal:
|
||||
path: spec.ports
|
||||
value:
|
||||
- name: otlp
|
||||
port: 4318
|
||||
targetPort: otlp
|
||||
- it: gives only Lens the ClickHouse secret
|
||||
template: lens/deployment.yaml
|
||||
set: *id001
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-storage
|
||||
key: url
|
||||
- it: requires an agent reachable ingestion URL
|
||||
template: deployment.yaml
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: ''
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.publicUrl is required when there is no single ingress hostname
|
||||
- it: omits Lens connection settings when disabled in deployment.yaml
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
any: true
|
||||
- it: preserves an existing ClickHouse database and retention
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
lensWorker.clickhouseDatabase: existing_traces
|
||||
lensWorker.retentionDays: 45
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_DATABASE
|
||||
value: existing_traces
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: AGENT_TRACING_RETENTION_DAYS
|
||||
value: '45'
|
||||
- it: keeps Lens pods outside the gateway autoscaling selector
|
||||
template: lens/deployment.yaml
|
||||
set: &id002
|
||||
nameOverride: inference
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.metadata.labels["app.kubernetes.io/name"]
|
||||
value: inference-lens-worker
|
||||
- it: preserves the existing gateway deployment selector
|
||||
template: deployment.yaml
|
||||
set: *id002
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/name"]
|
||||
value: inference
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
suite: Lens managed setup
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
release:
|
||||
namespace: lens
|
||||
name: lens-test
|
||||
templates:
|
||||
- lens/deployment.yaml
|
||||
- lens/clickhouse.yaml
|
||||
- lens/secrets.yaml
|
||||
tests:
|
||||
- it: generates private login, gateway and storage credentials
|
||||
template: lens/secrets.yaml
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 4
|
||||
- matchRegex:
|
||||
path: data.service-token
|
||||
pattern: '^[A-Za-z0-9+/]{86}==$'
|
||||
documentIndex: 0
|
||||
- matchRegex:
|
||||
path: data.password
|
||||
pattern: '^[A-Za-z0-9+/]{86}==$'
|
||||
documentIndex: 1
|
||||
- equal:
|
||||
path: metadata.annotations["helm.sh/resource-policy"]
|
||||
value: keep
|
||||
- it: connects Lens to its private bundled storage
|
||||
template: lens/deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_HOST
|
||||
value: lens-test-lens-clickhouse
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-test-lens-clickhouse
|
||||
key: password
|
||||
- it: stores traces on a persistent volume with the chosen storage class
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 1
|
||||
set:
|
||||
lensWorker.clickhouse.storage: 40Gi
|
||||
lensWorker.clickhouse.storageClassName: fast
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.volumeClaimTemplates[0].spec
|
||||
value:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: fast
|
||||
resources:
|
||||
requests:
|
||||
storage: 40Gi
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-test-lens-clickhouse
|
||||
key: password
|
||||
- it: keeps an existing external database instead of creating a new one
|
||||
template: lens/clickhouse.yaml
|
||||
set:
|
||||
lensWorker.clickhouseSecret.name: external-clickhouse
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
- it: leaves supplied secrets under their existing manager
|
||||
template: lens/secrets.yaml
|
||||
set:
|
||||
lensWorker.clickhouseSecret.name: external-clickhouse
|
||||
lensWorker.serviceTokenSecret.name: external-service
|
||||
lensWorker.adminTokenSecret.name: external-admin
|
||||
lensWorker.gateway.secretName: external-signing
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
- it: creates no credentials or database when Lens is disabled
|
||||
templates:
|
||||
- lens/secrets.yaml
|
||||
- lens/clickhouse.yaml
|
||||
set:
|
||||
lensWorker.enabled: false
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
- it: requires an external database when bundled storage is explicitly disabled
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.clickhouse.enabled: false
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: Lens clickhouseSecret.name is required when bundled storage is disabled
|
||||
- it: keeps storage names valid and uses the same name for the connection
|
||||
set:
|
||||
fullnameOverride: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
asserts:
|
||||
- matchRegex:
|
||||
path: metadata.name
|
||||
pattern: '^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$'
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 0
|
||||
- matchRegex:
|
||||
path: metadata.name
|
||||
pattern: '^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$'
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 1
|
||||
- equal:
|
||||
path: metadata.name
|
||||
value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 0
|
||||
- equal:
|
||||
path: spec.serviceName
|
||||
value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 1
|
||||
- equal:
|
||||
path: metadata.name
|
||||
value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
|
||||
template: lens/secrets.yaml
|
||||
documentIndex: 1
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_HOST
|
||||
value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
|
||||
template: lens/deployment.yaml
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
|
||||
key: password
|
||||
template: lens/deployment.yaml
|
||||
|
||||
- it: allows cold storage startup and inherits registry credentials
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 1
|
||||
set:
|
||||
imagePullSecrets: [{name: registry-auth}]
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.spec.imagePullSecrets
|
||||
value: [{name: registry-auth}]
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].startupProbe
|
||||
value:
|
||||
httpGet: {path: /ping, port: http}
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 60
|
||||
|
|
@ -154,19 +154,13 @@ masterkeySecretName: ""
|
|||
# if set, use this secret key for the master key; otherwise, use the default key
|
||||
masterkeySecretKey: ""
|
||||
|
||||
# Optional: enterprise billable-request metering. When enabled, the proxy counts
|
||||
# successful requests to inference, MCP, and A2A endpoints and pushes them to
|
||||
# LiteLLM's collector over mutual TLS. Requires an enterprise license.
|
||||
# The client certificate identifies the deployment, so it is mounted read-only
|
||||
# from an existing Secret and never passed through the environment.
|
||||
# Optional: enterprise billable-request metering. Requires an enterprise license.
|
||||
billingMetrics:
|
||||
enabled: false
|
||||
endpoint: https://telemetry.litellm.ai # collector to push the counter to
|
||||
secretName: litellm-billing-metrics-mtls # existing Secret holding tls.crt and tls.key
|
||||
# Only for private or test collectors whose server certificate is not on the
|
||||
# public web PKI. The production collector needs no CA override.
|
||||
caSecretName: "" # existing Secret holding ca.crt
|
||||
exportIntervalMs: "" # push cadence; the proxy defaults to 60000
|
||||
endpoint: https://telemetry.litellm.ai
|
||||
tokenUrl: https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token
|
||||
secretName: litellm-billing-metrics
|
||||
exportIntervalMs: ""
|
||||
|
||||
proxyConfigMap:
|
||||
# when true, creates a new configmap
|
||||
|
|
@ -668,70 +662,15 @@ serviceMonitor:
|
|||
# - test-namespace
|
||||
|
||||
lens:
|
||||
library: true
|
||||
|
||||
lensWorker:
|
||||
retainResources: false
|
||||
mode: ""
|
||||
externalUrl: ""
|
||||
externalServiceName: ""
|
||||
gateway:
|
||||
secretName: ""
|
||||
secretKey: gateway-secret
|
||||
standaloneUrl: ""
|
||||
adminTokenSecret:
|
||||
name: ""
|
||||
key: admin-token
|
||||
extraEnv: []
|
||||
extraEnvFrom: []
|
||||
enabled: false
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: ghcr.io/berriai/lens
|
||||
tag: "0.1.0-dev.0"
|
||||
digest: ""
|
||||
pullPolicy: IfNotPresent
|
||||
tokenSecret:
|
||||
url: ""
|
||||
publicUrl: ""
|
||||
service:
|
||||
name: ""
|
||||
key: token
|
||||
port: 4318
|
||||
gatewaySecret:
|
||||
name: ""
|
||||
key: gateway-secret
|
||||
serviceTokenSecret:
|
||||
name: ""
|
||||
key: service-token
|
||||
clickhouse:
|
||||
enabled: true
|
||||
image: clickhouse/clickhouse-server:26.9.6.6@sha256:eb4870e7ca7ed70c259eebfcfbee6cf797017f6b5436c2926bbbfe3d4d28486e
|
||||
storage: 20Gi
|
||||
storageClassName: null
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
clickhouseDatabase: litellm
|
||||
retentionDays: 14
|
||||
clickhouseSecret:
|
||||
name: ""
|
||||
key: url
|
||||
publicUrl: ""
|
||||
service:
|
||||
port: 4318
|
||||
annotations: {}
|
||||
ingress:
|
||||
enabled: false
|
||||
path: /v1/
|
||||
className: ""
|
||||
host: ""
|
||||
annotations: {}
|
||||
tls: []
|
||||
url: ""
|
||||
tmpSizeLimit: 1Gi
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
dependencies:
|
||||
- name: lens
|
||||
repository: oci://ghcr.io/berriai/charts
|
||||
version: 0.1.0-dev.0
|
||||
digest: sha256:6b244c878890f10a093a297d70c9e68b2c3bda00fbbb56ab287e7ecee1d7bbcf
|
||||
generated: '2026-10-09T04:18:10.642706000Z'
|
||||
|
|
@ -2,12 +2,7 @@ apiVersion: v2
|
|||
name: litellm
|
||||
description: LiteLLM componentized — gateway, UI backend, and UI as separate services
|
||||
type: application
|
||||
version: 0.1.0
|
||||
version: 0.1.1
|
||||
appVersion: "0.1.0"
|
||||
annotations:
|
||||
org.opencontainers.image.source: "https://github.com/BerriAI/litellm"
|
||||
|
||||
dependencies:
|
||||
- name: lens
|
||||
version: 0.1.0-dev.0
|
||||
repository: oci://ghcr.io/berriai/charts
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
# LiteLLM componentized chart
|
||||
|
||||
This chart deploys separate gateway, backend and UI services. Configure Lens through `lensWorker.mode`: `disabled` keeps it off, `bundled` installs the Lens dependency, and `external` connects an existing Lens service
|
||||
This chart deploys separate gateway, backend and UI services
|
||||
|
||||
Bundled mode generates separate service and identity-signing credentials. External mode requires `lensWorker.gateway.secretName` and `lensWorker.serviceTokenSecret.name`; their keys default to `gateway-secret` and `service-token`. Provision distinct values matching the external Lens deployment
|
||||
|
||||
Follow the [Lens Helm connection guide](https://github.com/BerriAI/lens/blob/main/helm/lens/README.md#connect-a-gateway) for the complete values and verification flow. It also links the GitOps credential requirements and existing-data migration. Source-chart installation requires a built Lens image until the first signed Lens release is published
|
||||
This chart does not deploy Lens. Deploy Lens from [its own chart](https://github.com/BerriAI/lens/tree/main/helm/lens), then set `lens.enabled: true`, `lens.url`, `lens.publicUrl`, `lens.gatewaySecret.name` and `lens.serviceTokenSecret.name`. The secret keys default to `gateway-secret` and `service-token`. Set `lens.service.name` when that Lens runs in this release's namespace and the chart ingress should route `/lens-ingest` to it. The old `lensWorker` values are no longer read, and enabling them fails the render
|
||||
|
||||
For an ingress with separate component hostnames, set all three `ingress.componentHosts` values, leave `ingress.host` and `ingress.extraPaths` empty, configure `ui.backendUrl` as the backend's public origin and `backend.gatewayUrl` as the gateway's public origin, and set `ui.publicUrl` to the browser-facing UI origin. The UI Deployment receives `LITELLM_BACKEND_URL` from `ui.backendUrl` only when `ingress.componentHosts` is configured.
|
||||
|
||||
|
|
@ -24,6 +22,23 @@ backend:
|
|||
gatewayUrl: https://gateway.example.com
|
||||
```
|
||||
|
||||
## Enterprise billing metrics
|
||||
|
||||
Billing metrics use Cognito bearer authentication. Create the Secret with
|
||||
`client-id` and `client-secret` keys, then enable billing metrics:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic litellm-billing-metrics --from-literal=client-id=... --from-literal=client-secret=...
|
||||
```
|
||||
|
||||
```yaml
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
The chart requires an HTTPS endpoint, an HTTPS token URL, and the Secret name
|
||||
when billing metrics are enabled
|
||||
|
||||
## Routes to another Service
|
||||
|
||||
For a shared hostname, `ingress.extraPaths` can route to a Service in the same namespace using `serviceName` and `servicePort`. The port can be a number or a named Service port. Omit `service` when using these fields
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -58,56 +58,27 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|||
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Enterprise billable-request metering. Wired into gateway and backend, not the
|
||||
migrations job. The gateway serves nearly all billable traffic, but the backend
|
||||
keeps the named-server MCP transport (/{mcp_server_name}/mcp), which writes a
|
||||
SpendLogs row, so metering only the gateway would silently drop that traffic.
|
||||
The client certificate identifies the deployment to LiteLLM's collector, so it is
|
||||
mounted read-only from an existing Secret rather than passed through the
|
||||
environment.
|
||||
*/}}
|
||||
{{- define "litellm.billingMetrics.certDir" -}}/etc/litellm/billing-mtls{{- end -}}
|
||||
{{- define "litellm.billingMetrics.caDir" -}}/etc/litellm/billing-mtls-ca{{- end -}}
|
||||
|
||||
{{- define "litellm.billingMetricsEnv" -}}
|
||||
- name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: {{ required "billingMetrics.endpoint is required when billingMetrics.enabled is true" .Values.billingMetrics.endpoint | quote }}
|
||||
- name: LITELLM_BILLING_METRICS_CLIENT_CERT
|
||||
value: {{ printf "%s/tls.crt" (include "litellm.billingMetrics.certDir" .) | quote }}
|
||||
- name: LITELLM_BILLING_METRICS_CLIENT_KEY
|
||||
value: {{ printf "%s/tls.key" (include "litellm.billingMetrics.certDir" .) | quote }}
|
||||
{{- if .Values.billingMetrics.caSecretName }}
|
||||
- name: LITELLM_BILLING_METRICS_CA_CERT
|
||||
value: {{ printf "%s/ca.crt" (include "litellm.billingMetrics.caDir" .) | quote }}
|
||||
{{- end }}
|
||||
- name: LITELLM_BILLING_METRICS_TOKEN_URL
|
||||
value: {{ required "billingMetrics.tokenUrl is required when billingMetrics.enabled is true" .Values.billingMetrics.tokenUrl | quote }}
|
||||
- name: LITELLM_BILLING_METRICS_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true" .Values.billingMetrics.secretName }}
|
||||
key: client-id
|
||||
- name: LITELLM_BILLING_METRICS_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true" .Values.billingMetrics.secretName }}
|
||||
key: client-secret
|
||||
{{- with .Values.billingMetrics.exportIntervalMs }}
|
||||
- name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.billingMetricsVolumes" -}}
|
||||
- name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: {{ required "billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key)" .Values.billingMetrics.secretName }}
|
||||
{{- if .Values.billingMetrics.caSecretName }}
|
||||
- name: billing-metrics-mtls-ca
|
||||
secret:
|
||||
secretName: {{ .Values.billingMetrics.caSecretName }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.billingMetricsVolumeMounts" -}}
|
||||
- name: billing-metrics-mtls
|
||||
mountPath: {{ include "litellm.billingMetrics.certDir" . }}
|
||||
readOnly: true
|
||||
{{- if .Values.billingMetrics.caSecretName }}
|
||||
- name: billing-metrics-mtls-ca
|
||||
mountPath: {{ include "litellm.billingMetrics.caDir" . }}
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Per-component selector labels — used in both Service selectors and Deployment matchLabels.
|
||||
|
|
@ -604,10 +575,6 @@ Directory of the collector's unix socket, shared by the gateway and
|
|||
collector containers through an emptyDir. Empty when the sidecar is off
|
||||
or gateway.collector.address is a tcp://127.0.0.1:<port> address.
|
||||
*/}}
|
||||
{{- define "litellm.lensWorker.image" -}}
|
||||
{{- include "lens.image" (dict "Values" .Values.lensWorker "Chart" .Subcharts.lens.Chart) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.gateway.collectorSocketDir" -}}
|
||||
{{- if and .Values.gateway.collector.enabled (hasPrefix "unix://" .Values.gateway.collector.address) -}}
|
||||
{{- dir (trimPrefix "unix://" .Values.gateway.collector.address) -}}
|
||||
|
|
@ -635,93 +602,27 @@ shutdown drain window.
|
|||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensConnectionEnv" -}}
|
||||
{{- $mode := include "litellm.lens.mode" . -}}
|
||||
{{- if ne $mode "disabled" }}
|
||||
{{- if and (eq $mode "external") (not .Values.lensWorker.serviceTokenSecret.name) -}}
|
||||
{{- fail "lensWorker.serviceTokenSecret.name is required for external Lens" -}}
|
||||
{{- with .Values.lensWorker }}
|
||||
{{- if or .enabled (and .mode (ne .mode "disabled")) }}
|
||||
{{- fail "lensWorker was removed; deploy Lens from its own chart and set lens.* instead, see the chart README" }}
|
||||
{{- end }}
|
||||
{{- if and (eq $mode "external") (not .Values.lensWorker.gateway.secretName) -}}
|
||||
{{- fail "lensWorker.gateway.secretName is required for external Lens" -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- with .Values.lens }}
|
||||
{{- if .enabled }}
|
||||
- name: LITELLM_LENS_URL
|
||||
value: {{ if eq $mode "external" }}{{ required "lensWorker.externalUrl is required for external Lens" .Values.lensWorker.externalUrl | quote }}{{ else }}{{ printf "http://%s-lens-worker:%v" (include "litellm.fullname" .) .Values.lensWorker.service.port | quote }}{{ end }}
|
||||
value: {{ required "lens.url is required when lens.enabled is true" .url | quote }}
|
||||
- name: LITELLM_LENS_PUBLIC_URL
|
||||
value: {{ include "litellm.lensWorker.publicUrl" . | quote }}
|
||||
value: {{ required "lens.publicUrl is required when lens.enabled is true" .publicUrl | quote }}
|
||||
- name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "litellm.lensWorker.gatewaySecretName" . | quote }}
|
||||
key: {{ .Values.lensWorker.gateway.secretKey | quote }}
|
||||
name: {{ required "lens.gatewaySecret.name is required when lens.enabled is true" .gatewaySecret.name | quote }}
|
||||
key: {{ .gatewaySecret.key | quote }}
|
||||
- name: LITELLM_LENS_SERVICE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "litellm.lensWorker.serviceTokenSecretName" . | quote }}
|
||||
key: {{ .Values.lensWorker.serviceTokenSecret.key | quote }}
|
||||
name: {{ required "lens.serviceTokenSecret.name is required when lens.enabled is true" .serviceTokenSecret.name | quote }}
|
||||
key: {{ .serviceTokenSecret.key | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.labels" -}}
|
||||
{{- $labels := include "litellm.commonLabels" . | fromYaml -}}
|
||||
{{- $_ := set $labels "app.kubernetes.io/name" (printf "%s-lens-worker" (include "litellm.name" . | trunc 51 | trimSuffix "-")) -}}
|
||||
{{- toYaml $labels -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.serviceTokenSecretName" -}}
|
||||
{{- .Values.lensWorker.serviceTokenSecret.name | default (printf "%s-lens-service" (include "litellm.fullname" .)) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.gatewaySecretName" -}}
|
||||
{{- .Values.lensWorker.gateway.secretName | default (printf "%s-lens-gateway" (include "litellm.fullname" . | trunc 50 | trimSuffix "-")) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.bundledClickhouse" -}}
|
||||
{{- if and (eq (include "litellm.lens.mode" .) "bundled") .Values.lensWorker.clickhouse.enabled (not .Values.lensWorker.clickhouseSecret.name) -}}true{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.publicUrl" -}}
|
||||
{{- if .Values.lensWorker.publicUrl -}}
|
||||
{{- .Values.lensWorker.publicUrl -}}
|
||||
{{- else if eq (include "litellm.lens.mode" .) "external" -}}
|
||||
{{- fail "lensWorker.publicUrl is required for external Lens" -}}
|
||||
{{- else if .Values.lensWorker.ingress.enabled -}}
|
||||
{{- $tls := or (not (empty .Values.lensWorker.ingress.tls)) (hasKey .Values.lensWorker.ingress.annotations "alb.ingress.kubernetes.io/certificate-arn") -}}
|
||||
{{- printf "%s://%s" (ternary "https" "http" $tls) (required "lensWorker.ingress.host is required" .Values.lensWorker.ingress.host) -}}
|
||||
{{- else if and .Values.ingress.enabled .Values.ingress.host -}}
|
||||
{{- $tls := or (not (empty .Values.ingress.tls)) (hasKey .Values.ingress.annotations "alb.ingress.kubernetes.io/certificate-arn") -}}
|
||||
{{- printf "%s://%s/lens-ingest" (ternary "https" "http" $tls) .Values.ingress.host -}}
|
||||
{{- else -}}
|
||||
{{- fail "lensWorker.publicUrl is required when there is no single ingress hostname" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lensWorker.clickhouseName" -}}
|
||||
{{- printf "%s-lens-clickhouse" (include "litellm.fullname" . | trunc 47 | trimSuffix "-") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lens.mode" -}}
|
||||
{{- $mode := .Values.lensWorker.mode | default (ternary "bundled" "disabled" .Values.lensWorker.enabled) -}}
|
||||
{{- if not (has $mode (list "bundled" "external" "disabled")) -}}
|
||||
{{- fail "lensWorker.mode must be bundled, external or disabled" -}}
|
||||
{{- end -}}
|
||||
{{- $mode -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "litellm.lens.render" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $values := mergeOverwrite (deepCopy $root.Subcharts.lens.Values) (deepCopy $root.Values.lensWorker) -}}
|
||||
{{- $_ := set $values "fullnameOverride" (printf "%s-lens-worker" (include "litellm.fullname" $root)) -}}
|
||||
{{- $_ := set $values "component" "lens-worker" -}}
|
||||
{{- $_ := set $values "nameOverride" (printf "%s-lens-worker" (include "litellm.name" $root | trunc 51 | trimSuffix "-")) -}}
|
||||
{{- $_ := set $values "imagePullSecrets" $root.Values.imagePullSecrets -}}
|
||||
{{- $_ := set $values.gateway "enabled" true -}}
|
||||
{{- $_ := set $values.gateway "generatedName" (include "litellm.lensWorker.gatewaySecretName" $root) -}}
|
||||
{{- $_ := set $values.clickhouse "nameOverride" (include "litellm.lensWorker.clickhouseName" $root) -}}
|
||||
{{- $_ := set $values.serviceTokenSecret "generatedName" (include "litellm.lensWorker.serviceTokenSecretName" $root) -}}
|
||||
{{- $_ := set $values "publicUrl" ($root.Values.lensWorker.standaloneUrl | default $root.Subcharts.lens.Values.publicUrl) -}}
|
||||
{{- if and (eq .resource "deployment") (or $root.Values.lensWorker.publicUrl $root.Values.lensWorker.ingress.enabled $root.Values.ingress.enabled) -}}
|
||||
{{- $ingestion := include "litellm.lensWorker.publicUrl" $root -}}
|
||||
{{- $_ := set $values "ingestionUrl" $ingestion -}}
|
||||
{{- $_ := set $values "publicUrl" ($root.Values.lensWorker.standaloneUrl | default (trimSuffix "/lens-ingest" $ingestion)) -}}
|
||||
{{- end -}}
|
||||
{{- include (printf "lens.%s" .resource) (dict "Values" $values "Release" $root.Release "Chart" $root.Subcharts.lens.Chart "Capabilities" $root.Capabilities) -}}
|
||||
{{- end -}}
|
||||
|
|
|
|||
|
|
@ -79,16 +79,13 @@ spec:
|
|||
{{- include "litellm.billingMetricsEnv" . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- include "litellm.envFrom" .Values.backend | nindent 10 }}
|
||||
{{- if or .Values.gateway.config.create .Values.backend.volumeMounts .Values.billingMetrics.enabled }}
|
||||
{{- if or .Values.gateway.config.create .Values.backend.volumeMounts }}
|
||||
volumeMounts:
|
||||
{{- if .Values.gateway.config.create }}
|
||||
- name: gateway-config
|
||||
mountPath: /app/config/config.yaml
|
||||
subPath: config.yaml
|
||||
{{- end }}
|
||||
{{- if .Values.billingMetrics.enabled }}
|
||||
{{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
|
|
@ -114,16 +111,13 @@ spec:
|
|||
{{- with .Values.backend.extraContainers }}
|
||||
{{- tpl (toYaml .) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.gateway.config.create .Values.backend.volumes .Values.billingMetrics.enabled }}
|
||||
{{- if or .Values.gateway.config.create .Values.backend.volumes }}
|
||||
volumes:
|
||||
{{- if .Values.gateway.config.create }}
|
||||
- name: gateway-config
|
||||
configMap:
|
||||
name: {{ include "litellm.gateway.fullname" . }}-config
|
||||
{{- end }}
|
||||
{{- if .Values.billingMetrics.enabled }}
|
||||
{{- include "litellm.billingMetricsVolumes" . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ spec:
|
|||
{{- include "litellm.gateway.collectorEnv" . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- include "litellm.envFrom" .Values.gateway | nindent 10 }}
|
||||
{{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.billingMetrics.enabled .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }}
|
||||
{{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }}
|
||||
volumeMounts:
|
||||
{{- if .Values.gateway.config.create }}
|
||||
- name: gateway-config
|
||||
|
|
@ -101,9 +101,6 @@ spec:
|
|||
- name: collector-socket
|
||||
mountPath: {{ include "litellm.gateway.collectorSocketDir" . }}
|
||||
{{- end }}
|
||||
{{- if .Values.billingMetrics.enabled }}
|
||||
{{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.gateway.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
|
|
@ -206,7 +203,7 @@ spec:
|
|||
{{- with .Values.gateway.extraContainers }}
|
||||
{{- tpl (toYaml .) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.billingMetrics.enabled .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }}
|
||||
{{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.gateway.metricsServer.enabled (include "litellm.gateway.collectorSocketDir" .) }}
|
||||
volumes:
|
||||
{{- if .Values.gateway.config.create }}
|
||||
- name: gateway-config
|
||||
|
|
@ -222,9 +219,6 @@ spec:
|
|||
emptyDir:
|
||||
sizeLimit: 1Mi
|
||||
{{- end }}
|
||||
{{- if .Values.billingMetrics.enabled }}
|
||||
{{- include "litellm.billingMetricsVolumes" . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.gateway.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
|
|
|||
|
|
@ -166,14 +166,14 @@ spec:
|
|||
- host: {{ $componentHosts.gateway | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- if or (eq (include "litellm.lens.mode" .) "bundled") (and (eq (include "litellm.lens.mode" .) "external") .Values.lensWorker.externalServiceName) }}
|
||||
{{- if and .Values.lens.enabled .Values.lens.service.name }}
|
||||
- path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Values.lensWorker.externalServiceName | default (printf "%s-lens-worker" (include "litellm.fullname" .)) }}
|
||||
name: {{ .Values.lens.service.name }}
|
||||
port:
|
||||
number: {{ .Values.lensWorker.service.port }}
|
||||
number: {{ .Values.lens.service.port }}
|
||||
{{- end }}
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
|
|
@ -249,15 +249,15 @@ spec:
|
|||
port:
|
||||
number: {{ $gatewayPort }}
|
||||
{{- end }}
|
||||
{{- if or (eq (include "litellm.lens.mode" .) "bundled") (and (eq (include "litellm.lens.mode" .) "external") .Values.lensWorker.externalServiceName) }}
|
||||
{{- if and .Values.lens.enabled .Values.lens.service.name }}
|
||||
{{- $builtinPathKeys = append $builtinPathKeys "/lens-ingest|Prefix" }}
|
||||
- path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Values.lensWorker.externalServiceName | default (printf "%s-lens-worker" (include "litellm.fullname" .)) }}
|
||||
name: {{ .Values.lens.service.name }}
|
||||
port:
|
||||
number: {{ .Values.lensWorker.service.port }}
|
||||
number: {{ .Values.lens.service.port }}
|
||||
{{- end }}
|
||||
{{- /*
|
||||
--- Operator-supplied extra paths (ingress.extraPaths) ---
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
{{- if eq (include "litellm.lens.mode" .) "bundled" }}
|
||||
{{ include "litellm.lens.render" (dict "root" . "resource" "clickhouse") }}
|
||||
{{- end }}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{{- if eq (include "litellm.lens.mode" .) "bundled" }}
|
||||
{{ include "litellm.lens.render" (dict "root" . "resource" "deployment") }}
|
||||
{{- end }}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{{- if eq (include "litellm.lens.mode" .) "bundled" }}
|
||||
{{ include "litellm.lens.render" (dict "root" . "resource" "ingress") }}
|
||||
{{- end }}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{{- if eq (include "litellm.lens.mode" .) "bundled" }}
|
||||
{{ include "litellm.lens.render" (dict "root" . "resource" "secrets") }}
|
||||
{{- end }}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{{- if eq (include "litellm.lens.mode" .) "bundled" }}
|
||||
{{ include "litellm.lens.render" (dict "root" . "resource" "service") }}
|
||||
{{- end }}
|
||||
|
|
@ -1,39 +1,30 @@
|
|||
suite: test billingMetrics wiring on gateway and backend
|
||||
suite: test bearer-only billingMetrics wiring
|
||||
templates:
|
||||
- gateway/deployment.yaml
|
||||
- gateway/configmap.yaml
|
||||
- backend/deployment.yaml
|
||||
- backend/configmap.yaml
|
||||
- migrations-job.yaml
|
||||
values:
|
||||
- ./values/required.yaml
|
||||
tests:
|
||||
- it: is off by default, adding no env, volume, or mount
|
||||
- it: is disabled by default
|
||||
template: gateway/deployment.yaml
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: billing-mtls
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
value:
|
||||
- name: gateway-config
|
||||
mountPath: /app/config/config.yaml
|
||||
subPath: config.yaml
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: https://telemetry.litellm.ai
|
||||
- notContains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics
|
||||
|
||||
- it: renders the endpoint and the mounted cert paths when enabled
|
||||
- it: wires gateway URLs and bearer credentials from Secret keys
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: billing-mtls
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
|
|
@ -43,87 +34,68 @@ tests:
|
|||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_CLIENT_CERT
|
||||
value: /etc/litellm/billing-mtls/tls.crt
|
||||
name: LITELLM_BILLING_METRICS_TOKEN_URL
|
||||
value: https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_CLIENT_KEY
|
||||
value: /etc/litellm/billing-mtls/tls.key
|
||||
|
||||
- it: mounts the cert secret read-only alongside the config volume
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: billing-mtls
|
||||
asserts:
|
||||
name: LITELLM_BILLING_METRICS_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: litellm-billing-metrics
|
||||
key: client-id
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: billing-mtls
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
mountPath: /etc/litellm/billing-mtls
|
||||
readOnly: true
|
||||
|
||||
# The production collector presents a public web-PKI certificate, so the CA
|
||||
# override must stay absent unless a private collector is configured.
|
||||
- it: omits the CA env, volume, and mount when no caSecretName is set
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: billing-mtls
|
||||
asserts:
|
||||
name: LITELLM_BILLING_METRICS_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: litellm-billing-metrics
|
||||
key: client-secret
|
||||
- notContains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls-ca
|
||||
secret:
|
||||
secretName: billing-ca
|
||||
name: billing-metrics
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_CA_CERT
|
||||
value: /etc/litellm/billing-mtls-ca/ca.crt
|
||||
name: billing-metrics
|
||||
|
||||
- it: mounts the CA secret when caSecretName is set
|
||||
template: gateway/deployment.yaml
|
||||
- it: wires billing metrics on the backend without volumes
|
||||
template: backend/deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: billing-mtls
|
||||
caSecretName: billing-ca
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_CA_CERT
|
||||
value: /etc/litellm/billing-mtls-ca/ca.crt
|
||||
- contains:
|
||||
name: LITELLM_BILLING_METRICS_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: litellm-billing-metrics
|
||||
key: client-secret
|
||||
- notContains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls-ca
|
||||
secret:
|
||||
secretName: billing-ca
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: billing-metrics-mtls-ca
|
||||
mountPath: /etc/litellm/billing-mtls-ca
|
||||
readOnly: true
|
||||
name: billing-metrics
|
||||
|
||||
- it: passes the export interval through only when set
|
||||
- it: does not add billing metrics to migrations
|
||||
template: migrations-job.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
|
||||
- it: renders an optional export interval
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: billing-mtls
|
||||
exportIntervalMs: 5000
|
||||
asserts:
|
||||
- contains:
|
||||
|
|
@ -132,102 +104,27 @@ tests:
|
|||
name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS
|
||||
value: "5000"
|
||||
|
||||
- it: keeps user-supplied gateway volumes alongside the billing secret
|
||||
- it: requires the endpoint when enabled
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: billing-mtls
|
||||
gateway.volumes:
|
||||
- name: custom-callbacks
|
||||
configMap:
|
||||
name: my-callbacks
|
||||
gateway.volumeMounts:
|
||||
- name: custom-callbacks
|
||||
mountPath: /app/callbacks
|
||||
endpoint: ""
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: custom-callbacks
|
||||
configMap:
|
||||
name: my-callbacks
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: billing-mtls
|
||||
- failedTemplate:
|
||||
errorMessage: billingMetrics.endpoint is required when billingMetrics.enabled is true
|
||||
|
||||
# The backend keeps the named-server MCP transport (/{mcp_server_name}/mcp),
|
||||
# which writes a SpendLogs row, so it must meter too or that traffic is lost.
|
||||
- it: meters the backend as well, since it serves the MCP transport
|
||||
template: backend/deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: billing-mtls
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: https://telemetry.litellm.ai
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].volumeMounts
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
mountPath: /etc/litellm/billing-mtls
|
||||
readOnly: true
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: billing-mtls
|
||||
|
||||
- it: leaves the backend alone when metering is off
|
||||
template: backend/deployment.yaml
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: https://telemetry.litellm.ai
|
||||
|
||||
# The migrations job runs prisma and serves no traffic; it must never receive
|
||||
# the client key.
|
||||
- it: never mounts the billing cert on the migrations job
|
||||
template: migrations-job.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
secretName: billing-mtls
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_BILLING_METRICS_ENDPOINT
|
||||
value: https://telemetry.litellm.ai
|
||||
- isNull:
|
||||
path: spec.template.spec.volumes
|
||||
|
||||
# The conventional Secret name is the default, so enabling metering needs no
|
||||
# secretName at all; the guard below only fires on an explicitly blanked one.
|
||||
- it: uses the conventional secret name by default
|
||||
- it: requires the token URL when enabled
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
tokenUrl: ""
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.volumes
|
||||
content:
|
||||
name: billing-metrics-mtls
|
||||
secret:
|
||||
secretName: litellm-billing-metrics-mtls
|
||||
- failedTemplate:
|
||||
errorMessage: billingMetrics.tokenUrl is required when billingMetrics.enabled is true
|
||||
|
||||
- it: fails loudly when the secretName is explicitly blanked
|
||||
- it: requires the secret name when enabled
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
|
|
@ -235,15 +132,4 @@ tests:
|
|||
secretName: ""
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true (an existing Secret with tls.crt and tls.key)
|
||||
|
||||
- it: fails loudly when enabled without an endpoint
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
billingMetrics:
|
||||
enabled: true
|
||||
endpoint: ""
|
||||
secretName: billing-mtls
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: billingMetrics.endpoint is required when billingMetrics.enabled is true
|
||||
errorMessage: billingMetrics.secretName is required when billingMetrics.enabled is true
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ tests:
|
|||
ingress.componentHosts.ui: ui.example.com
|
||||
ingress.componentHosts.backend: backend.example.com
|
||||
ingress.componentHosts.gateway: gateway.example.com
|
||||
lensWorker.mode: disabled
|
||||
ingress.tls:
|
||||
- hosts:
|
||||
- ui.example.com
|
||||
|
|
@ -83,12 +82,12 @@ tests:
|
|||
ui.backendUrl: https://backend.example.com
|
||||
ui.publicUrl: https://ui.example.com
|
||||
backend.gatewayUrl: https://gateway.example.com
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalServiceName: existing-lens
|
||||
lensWorker.externalUrl: https://lens.example.com
|
||||
lensWorker.publicUrl: https://lens.example.com
|
||||
lensWorker.serviceTokenSecret.name: existing-lens-signing
|
||||
lensWorker.gateway.secretName: existing-lens-signing
|
||||
lens.enabled: true
|
||||
lens.service.name: existing-lens
|
||||
lens.url: http://existing-lens:4318
|
||||
lens.publicUrl: https://gateway.example.com/lens-ingest
|
||||
lens.serviceTokenSecret.name: existing-lens-signing
|
||||
lens.gatewaySecret.name: existing-lens-signing
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.rules[2].http.paths[0].path
|
||||
|
|
|
|||
166
helm/litellm/tests/lens_connection_tests.yaml
Normal file
166
helm/litellm/tests/lens_connection_tests.yaml
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
suite: lens connection
|
||||
templates:
|
||||
- gateway/deployment.yaml
|
||||
- backend/deployment.yaml
|
||||
- gateway/configmap.yaml
|
||||
- ingress.yaml
|
||||
values:
|
||||
- ./values/required.yaml
|
||||
tests:
|
||||
- it: connects the gateway to an external Lens
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
value: http://lens.lens.svc:4318
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://lens.example.com/lens-ingest
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-gateway
|
||||
key: gateway-secret
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_SERVICE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-service
|
||||
key: service-token
|
||||
|
||||
- it: connects the backend to an external Lens
|
||||
template: backend/deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
value: http://lens.lens.svc:4318
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://lens.example.com/lens-ingest
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-gateway
|
||||
key: gateway-secret
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_SERVICE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-service
|
||||
key: service-token
|
||||
|
||||
- it: requires lens.url when Lens is enabled
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lens.url is required when lens.enabled is true
|
||||
|
||||
- it: requires lens.publicUrl when Lens is enabled
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lens.publicUrl is required when lens.enabled is true
|
||||
|
||||
- it: requires lens.gatewaySecret.name when Lens is enabled
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lens.gatewaySecret.name is required when lens.enabled is true
|
||||
|
||||
- it: requires lens.serviceTokenSecret.name when Lens is enabled
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lens.serviceTokenSecret.name is required when lens.enabled is true
|
||||
|
||||
- it: routes /lens-ingest to the named Lens service on the single ingress host
|
||||
template: ingress.yaml
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.host: litellm.example.com
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
lens.service.name: existing-lens
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: existing-lens
|
||||
port:
|
||||
number: 4318
|
||||
|
||||
- it: leaves /lens-ingest off the ingress without a Lens service name
|
||||
template: ingress.yaml
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.host: litellm.example.com
|
||||
lens.enabled: true
|
||||
lens.url: http://lens.lens.svc:4318
|
||||
lens.publicUrl: https://lens.example.com/lens-ingest
|
||||
lens.gatewaySecret.name: lens-gateway
|
||||
lens.serviceTokenSecret.name: lens-service
|
||||
asserts:
|
||||
- exists:
|
||||
path: spec.rules[0].http.paths
|
||||
- notContains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
any: true
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
suite: Lens endpoint defaults
|
||||
templates:
|
||||
- gateway/deployment.yaml
|
||||
- gateway/configmap.yaml
|
||||
set:
|
||||
lensWorker.enabled: true
|
||||
ingress.enabled: true
|
||||
ingress.host: gateway.example
|
||||
ingress.tls:
|
||||
- hosts: [gateway.example]
|
||||
secretName: tls
|
||||
tests:
|
||||
- it: derives the trace endpoint from the deployment hostname and TLS
|
||||
template: gateway/deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://gateway.example/lens-ingest
|
||||
- it: preserves an explicitly configured public address
|
||||
set:
|
||||
lensWorker.publicUrl: https://custom.example/prefix
|
||||
template: gateway/deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://custom.example/prefix
|
||||
- it: uses the dedicated Lens ingress when configured
|
||||
set:
|
||||
lensWorker.ingress.enabled: true
|
||||
lensWorker.ingress.host: traces.example
|
||||
lensWorker.ingress.tls:
|
||||
- hosts: [traces.example]
|
||||
secretName: traces-tls
|
||||
template: gateway/deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://traces.example
|
||||
- it: uses HTTPS for a dedicated ALB ingress with certificate annotations
|
||||
set:
|
||||
lensWorker.ingress.enabled: true
|
||||
lensWorker.ingress.host: traces.example
|
||||
lensWorker.ingress.className: alb
|
||||
lensWorker.ingress.tls: []
|
||||
lensWorker.ingress.annotations:
|
||||
alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:123456789012:certificate/test
|
||||
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]'
|
||||
alb.ingress.kubernetes.io/ssl-redirect: "443"
|
||||
template: gateway/deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://traces.example
|
||||
- it: uses HTTP for a dedicated ingress without its own TLS
|
||||
set:
|
||||
lensWorker.ingress.enabled: true
|
||||
lensWorker.ingress.host: traces.example
|
||||
lensWorker.ingress.tls: []
|
||||
lensWorker.ingress.annotations: {}
|
||||
template: gateway/deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: http://traces.example
|
||||
- it: uses HTTP when ingress has no TLS
|
||||
set:
|
||||
ingress.tls: []
|
||||
template: gateway/deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: http://gateway.example/lens-ingest
|
||||
values:
|
||||
- ./values/required.yaml
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
---
|
||||
suite: Lens deployment modes
|
||||
templates:
|
||||
- gateway/configmap.yaml
|
||||
- backend/deployment.yaml
|
||||
- lens/deployment.yaml
|
||||
- lens/service.yaml
|
||||
- lens/clickhouse.yaml
|
||||
- lens/secrets.yaml
|
||||
- ingress.yaml
|
||||
values:
|
||||
- "./values/required.yaml"
|
||||
set:
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
tests:
|
||||
- it: retains the existing deployment before transferring its release ownership
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: bundled
|
||||
lensWorker.retainResources: true
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.annotations["helm.sh/resource-policy"]
|
||||
value: keep
|
||||
- it: keeps the ingestion endpoint on an independently deployed Lens service
|
||||
template: ingress.yaml
|
||||
set:
|
||||
ingress.enabled: true
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.externalServiceName: existing-lens
|
||||
lensWorker.serviceTokenSecret.name: external-lens-signing
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: existing-lens
|
||||
port: {number: 4318}
|
||||
- it: connects an external deployment without installing Lens resources
|
||||
templates:
|
||||
- lens/deployment.yaml
|
||||
- lens/service.yaml
|
||||
- lens/clickhouse.yaml
|
||||
- lens/secrets.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://lens.other-namespace:4318
|
||||
lensWorker.serviceTokenSecret.name: external-lens-signing
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
- it: sends delegated requests to the explicitly selected external Lens
|
||||
template: backend/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://lens.other-namespace:4318
|
||||
lensWorker.serviceTokenSecret.name: external-lens-signing
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
value: http://lens.other-namespace:4318
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: external-lens-signing
|
||||
key: gateway-secret
|
||||
- it: disables Lens explicitly even with a retained legacy enable flag
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: disabled
|
||||
lensWorker.enabled: true
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
- it: bundles Lens through explicit mode selection
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: bundled
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 1
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_MODE
|
||||
value: standalone
|
||||
- it: uses separate credentials for external identity and service access
|
||||
template: backend/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.serviceTokenSecret: {name: worker-service, key: token}
|
||||
lensWorker.gateway: {secretName: delegated-identity, secretKey: signature}
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: {name: delegated-identity, key: signature}
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_SERVICE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef: {name: worker-service, key: token}
|
||||
- it: supplies the same separate signing secret to the bundled service
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: bundled
|
||||
lensWorker.gateway: {secretName: delegated-identity, secretKey: signature}
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: {name: delegated-identity, key: signature}
|
||||
- it: routes explicit bundled mode with the enable flag off
|
||||
template: ingress.yaml
|
||||
set:
|
||||
fullnameOverride: gateway
|
||||
ingress.enabled: true
|
||||
ingress.host: gateway.example
|
||||
lensWorker.mode: bundled
|
||||
lensWorker.enabled: false
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.externalServiceName: ""
|
||||
lensWorker.serviceTokenSecret.name: worker-service
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gateway-lens-worker
|
||||
port: {number: 4318}
|
||||
- it: routes external mode to its selected service
|
||||
template: ingress.yaml
|
||||
set:
|
||||
fullnameOverride: gateway
|
||||
ingress.enabled: true
|
||||
ingress.host: gateway.example
|
||||
lensWorker.mode: external
|
||||
lensWorker.enabled: true
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.externalServiceName: "existing-lens"
|
||||
lensWorker.serviceTokenSecret.name: worker-service
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: existing-lens
|
||||
port: {number: 4318}
|
||||
- it: omits ingestion when an external service has no ingress backend
|
||||
template: ingress.yaml
|
||||
set:
|
||||
fullnameOverride: gateway
|
||||
ingress.enabled: true
|
||||
ingress.host: gateway.example
|
||||
lensWorker.mode: external
|
||||
lensWorker.enabled: true
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.externalServiceName: ""
|
||||
lensWorker.serviceTokenSecret.name: worker-service
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.rules[0].http.paths
|
||||
content: {path: /lens-ingest}
|
||||
any: true
|
||||
- it: omits ingestion for disabled mode with the enable flag on
|
||||
template: ingress.yaml
|
||||
set:
|
||||
fullnameOverride: gateway
|
||||
ingress.enabled: true
|
||||
ingress.host: gateway.example
|
||||
lensWorker.mode: disabled
|
||||
lensWorker.enabled: true
|
||||
lensWorker.externalUrl: http://existing-lens:4318
|
||||
lensWorker.externalServiceName: ""
|
||||
lensWorker.serviceTokenSecret.name: worker-service
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.rules[0].http.paths
|
||||
content: {path: /lens-ingest}
|
||||
any: true
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
---
|
||||
suite: Lens deployment mode validation
|
||||
templates:
|
||||
- gateway/configmap.yaml
|
||||
- backend/deployment.yaml
|
||||
values:
|
||||
- "./values/required.yaml"
|
||||
set:
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
tests:
|
||||
- it: requires a separate identity signing reference for external Lens
|
||||
template: backend/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://lens.other-namespace:4318
|
||||
lensWorker.serviceTokenSecret.name: worker-service
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.gateway.secretName is required for external Lens
|
||||
- it: requires an external service address
|
||||
template: backend/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.serviceTokenSecret.name: external-lens-signing
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.externalUrl is required for external Lens
|
||||
- it: requires the shared server credential for an external service
|
||||
template: backend/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://lens.other-namespace:4318
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.serviceTokenSecret.name is required for external Lens
|
||||
- it: requires an agent reachable URL for external ingestion
|
||||
template: backend/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: external
|
||||
lensWorker.externalUrl: http://lens.other-namespace:4318
|
||||
lensWorker.serviceTokenSecret.name: external-lens-signing
|
||||
lensWorker.gateway.secretName: external-lens-signing
|
||||
lensWorker.publicUrl: ''
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.publicUrl is required for external Lens
|
||||
- it: rejects unsupported deployment modes
|
||||
template: backend/deployment.yaml
|
||||
set:
|
||||
lensWorker.mode: typo
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.mode must be bundled, external or disabled
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
suite: Lens preserves credentials across Helm upgrades
|
||||
templates:
|
||||
- lens/secrets.yaml
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
release:
|
||||
namespace: lens
|
||||
name: lens-test
|
||||
upgrade: true
|
||||
kubernetesProvider:
|
||||
scheme:
|
||||
v1/Secret:
|
||||
gvr:
|
||||
version: v1
|
||||
resource: secrets
|
||||
namespaced: true
|
||||
objects:
|
||||
- apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: lens-test-lens-gateway
|
||||
namespace: lens
|
||||
data:
|
||||
gateway-secret: c2F2ZWQtc2lnbmluZy1rZXk=
|
||||
- apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: lens-test-lens-service
|
||||
namespace: lens
|
||||
data:
|
||||
service-token: c2F2ZWQtc2VydmljZS10b2tlbg==
|
||||
- apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: lens-test-lens-clickhouse
|
||||
namespace: lens
|
||||
data:
|
||||
password: c2F2ZWQtZGF0YWJhc2UtcGFzc3dvcmQ=
|
||||
tests:
|
||||
- it: preserves the separate signing credential across upgrades
|
||||
documentSelector: {path: metadata.name, value: lens-test-lens-gateway}
|
||||
asserts:
|
||||
- equal:
|
||||
path: data.gateway-secret
|
||||
value: c2F2ZWQtc2lnbmluZy1rZXk=
|
||||
- notExists: {path: data.service-token}
|
||||
- it: reuses the service credential instead of breaking running services
|
||||
documentIndex: 0
|
||||
asserts:
|
||||
- equal:
|
||||
path: data.service-token
|
||||
value: c2F2ZWQtc2VydmljZS10b2tlbg==
|
||||
- it: reuses the database password instead of locking out stored traces
|
||||
documentIndex: 1
|
||||
asserts:
|
||||
- equal:
|
||||
path: data.password
|
||||
value: c2F2ZWQtZGF0YWJhc2UtcGFzc3dvcmQ=
|
||||
values:
|
||||
- ./values/required.yaml
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
suite: Lens service isolation and ingestion routing
|
||||
templates:
|
||||
- gateway/configmap.yaml
|
||||
- gateway/deployment.yaml
|
||||
- backend/deployment.yaml
|
||||
- ingress.yaml
|
||||
- lens/ingress.yaml
|
||||
- lens/service.yaml
|
||||
- lens/deployment.yaml
|
||||
tests:
|
||||
- it: connects gateway/deployment.yaml to the shared Lens service
|
||||
template: gateway/deployment.yaml
|
||||
set: &id001
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://gateway.example/lens-ingest
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
value: http://lens-test-lens-worker:4318
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://gateway.example/lens-ingest
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-test-lens-gateway
|
||||
key: gateway-secret
|
||||
- it: connects backend/deployment.yaml to the shared Lens service
|
||||
template: backend/deployment.yaml
|
||||
set: *id001
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
value: http://lens-test-lens-worker:4318
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_PUBLIC_URL
|
||||
value: https://gateway.example/lens-ingest
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-test-lens-gateway
|
||||
key: gateway-secret
|
||||
- it: routes uploads directly to Lens instead of the gateway
|
||||
template: ingress.yaml
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://gateway.example/lens-ingest
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
ingress.enabled: true
|
||||
ingress.host: gateway.example
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.rules[0].http.paths
|
||||
content:
|
||||
path: /lens-ingest
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: lens-test-lens-worker
|
||||
port:
|
||||
number: 4318
|
||||
- it: keeps internal routes out of a dedicated ingestion hostname
|
||||
template: lens/ingress.yaml
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://gateway.example/lens-ingest
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
lensWorker.ingress.enabled: true
|
||||
lensWorker.ingress.host: traces.example
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.rules[0].http.paths
|
||||
value:
|
||||
- path: /v1/
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: lens-test-lens-worker
|
||||
port:
|
||||
name: otlp
|
||||
- it: maps the Lens service to the ingestion listener
|
||||
template: lens/service.yaml
|
||||
set: *id001
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.selector
|
||||
value:
|
||||
app.kubernetes.io/instance: RELEASE-NAME
|
||||
app.kubernetes.io/component: lens-worker
|
||||
- equal:
|
||||
path: spec.ports
|
||||
value:
|
||||
- name: otlp
|
||||
port: 4318
|
||||
targetPort: otlp
|
||||
- it: gives only Lens the ClickHouse secret
|
||||
template: lens/deployment.yaml
|
||||
set: *id001
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-storage
|
||||
key: url
|
||||
- it: requires an agent reachable ingestion URL
|
||||
template: gateway/deployment.yaml
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: ''
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: lensWorker.publicUrl is required when there is no single ingress hostname
|
||||
- it: omits Lens connection settings when disabled in gateway/deployment.yaml
|
||||
template: gateway/deployment.yaml
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
any: true
|
||||
- it: omits Lens connection settings when disabled in backend/deployment.yaml
|
||||
template: backend/deployment.yaml
|
||||
asserts:
|
||||
- notContains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LITELLM_LENS_URL
|
||||
any: true
|
||||
- it: preserves an existing ClickHouse database and retention
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
lensWorker.clickhouseDatabase: existing_traces
|
||||
lensWorker.retentionDays: 45
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_DATABASE
|
||||
value: existing_traces
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: AGENT_TRACING_RETENTION_DAYS
|
||||
value: '45'
|
||||
- it: keeps Lens pods outside the gateway autoscaling selector
|
||||
template: lens/deployment.yaml
|
||||
set: &id002
|
||||
nameOverride: inference
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.metadata.labels["app.kubernetes.io/name"]
|
||||
value: inference-lens-worker
|
||||
- it: preserves the existing gateway deployment selector
|
||||
template: gateway/deployment.yaml
|
||||
set: *id002
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/name"]
|
||||
value: inference
|
||||
values:
|
||||
- ./values/required.yaml
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
suite: Lens managed setup
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
release:
|
||||
namespace: lens
|
||||
name: lens-test
|
||||
templates:
|
||||
- lens/deployment.yaml
|
||||
- lens/clickhouse.yaml
|
||||
- lens/secrets.yaml
|
||||
tests:
|
||||
- it: generates private login, gateway and storage credentials
|
||||
template: lens/secrets.yaml
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 4
|
||||
- matchRegex:
|
||||
path: data.service-token
|
||||
pattern: '^[A-Za-z0-9+/]{86}==$'
|
||||
documentIndex: 0
|
||||
- matchRegex:
|
||||
path: data.password
|
||||
pattern: '^[A-Za-z0-9+/]{86}==$'
|
||||
documentIndex: 1
|
||||
- equal:
|
||||
path: metadata.annotations["helm.sh/resource-policy"]
|
||||
value: keep
|
||||
- it: connects Lens to its private bundled storage
|
||||
template: lens/deployment.yaml
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_HOST
|
||||
value: lens-test-lens-clickhouse
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-test-lens-clickhouse
|
||||
key: password
|
||||
- it: stores traces on a persistent volume with the chosen storage class
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 1
|
||||
set:
|
||||
lensWorker.clickhouse.storage: 40Gi
|
||||
lensWorker.clickhouse.storageClassName: fast
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.volumeClaimTemplates[0].spec
|
||||
value:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: fast
|
||||
resources:
|
||||
requests:
|
||||
storage: 40Gi
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: lens-test-lens-clickhouse
|
||||
key: password
|
||||
- it: keeps an existing external database instead of creating a new one
|
||||
template: lens/clickhouse.yaml
|
||||
set:
|
||||
lensWorker.clickhouseSecret.name: external-clickhouse
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
- it: leaves supplied secrets under their existing manager
|
||||
template: lens/secrets.yaml
|
||||
set:
|
||||
lensWorker.clickhouseSecret.name: external-clickhouse
|
||||
lensWorker.serviceTokenSecret.name: external-service
|
||||
lensWorker.adminTokenSecret.name: external-admin
|
||||
lensWorker.gateway.secretName: external-signing
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
- it: creates no credentials or database when Lens is disabled
|
||||
templates:
|
||||
- lens/secrets.yaml
|
||||
- lens/clickhouse.yaml
|
||||
set:
|
||||
lensWorker.enabled: false
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 0
|
||||
- it: requires an external database when bundled storage is explicitly disabled
|
||||
template: lens/deployment.yaml
|
||||
set:
|
||||
lensWorker.clickhouse.enabled: false
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: Lens clickhouseSecret.name is required when bundled storage is disabled
|
||||
- it: keeps storage names valid and uses the same name for the connection
|
||||
set:
|
||||
fullnameOverride: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
asserts:
|
||||
- matchRegex:
|
||||
path: metadata.name
|
||||
pattern: '^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$'
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 0
|
||||
- matchRegex:
|
||||
path: metadata.name
|
||||
pattern: '^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$'
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 1
|
||||
- equal:
|
||||
path: metadata.name
|
||||
value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 0
|
||||
- equal:
|
||||
path: spec.serviceName
|
||||
value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 1
|
||||
- equal:
|
||||
path: metadata.name
|
||||
value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
|
||||
template: lens/secrets.yaml
|
||||
documentIndex: 1
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_HOST
|
||||
value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
|
||||
template: lens/deployment.yaml
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: CLICKHOUSE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-lens-clickhouse
|
||||
key: password
|
||||
template: lens/deployment.yaml
|
||||
- it: allows cold storage startup and inherits registry credentials
|
||||
template: lens/clickhouse.yaml
|
||||
documentIndex: 1
|
||||
set:
|
||||
imagePullSecrets: [{name: registry-auth}]
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.spec.imagePullSecrets
|
||||
value: [{name: registry-auth}]
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].startupProbe
|
||||
value:
|
||||
httpGet: {path: /ping, port: http}
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 60
|
||||
|
||||
values:
|
||||
- ./values/required.yaml
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
suite: Independent Lens release and credentials
|
||||
templates:
|
||||
- lens/deployment.yaml
|
||||
values:
|
||||
- ./values/required.yaml
|
||||
set:
|
||||
fullnameOverride: lens-test
|
||||
lensWorker.enabled: true
|
||||
lensWorker.publicUrl: https://traces.example
|
||||
lensWorker.serviceTokenSecret.name: lens-service
|
||||
lensWorker.clickhouseSecret.name: lens-storage
|
||||
tests:
|
||||
- it: selects a Lens release independently of the gateway
|
||||
chart:
|
||||
appVersion: 9.8.7
|
||||
set:
|
||||
backend.image.tag: gateway-9.8.7
|
||||
lensWorker.image.tag: 2.3.4
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].image
|
||||
value: ghcr.io/berriai/lens:2.3.4
|
||||
- it: retains the pinned Lens chart default when only the gateway changes
|
||||
chart:
|
||||
appVersion: 9.8.7
|
||||
set:
|
||||
backend.image.tag: gateway-9.8.7
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].image
|
||||
value: ghcr.io/berriai/lens:0.1.0-dev.0
|
||||
- it: selects development publications explicitly
|
||||
set:
|
||||
lensWorker.image.repository: ghcr.io/berriai/lens-dev
|
||||
lensWorker.image.tag: sha-0123456789abcdef
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].image
|
||||
value: ghcr.io/berriai/lens-dev:sha-0123456789abcdef
|
||||
- it: preserves a verified registry mirror and digest
|
||||
set:
|
||||
lensWorker.image.repository: registry.example/lens
|
||||
lensWorker.image.tag: replaced-label
|
||||
lensWorker.image.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].image
|
||||
value: registry.example/lens@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
- it: rejects a malformed digest
|
||||
set:
|
||||
lensWorker.image.digest: sha256:invalid
|
||||
asserts:
|
||||
- failedTemplate:
|
||||
errorMessage: Lens image.digest must be sha256 followed by 64 lowercase hex characters
|
||||
- it: keeps the legacy enablement switch working
|
||||
set:
|
||||
lensWorker.enabled: false
|
||||
asserts:
|
||||
- hasDocuments: {count: 0}
|
||||
- it: keeps isolated local analysis with delegated gateway access
|
||||
asserts:
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content: {name: LENS_MODE, value: standalone}
|
||||
- contains:
|
||||
path: spec.template.spec.containers[0].env
|
||||
content:
|
||||
name: LENS_GATEWAY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: {name: lens-test-lens-gateway, key: gateway-secret}
|
||||
- equal:
|
||||
path: spec.template.spec.automountServiceAccountToken
|
||||
value: false
|
||||
- equal:
|
||||
path: spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem
|
||||
value: true
|
||||
- equal:
|
||||
path: spec.template.spec.volumes[0].emptyDir
|
||||
value: {medium: Memory, sizeLimit: 1Gi}
|
||||
|
|
@ -91,7 +91,7 @@ tests:
|
|||
app.kubernetes.io/name: litellm
|
||||
app.kubernetes.io/instance: RELEASE-NAME
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: litellm-0.1.0
|
||||
helm.sh/chart: litellm-0.1.1
|
||||
app.kubernetes.io/component: migrations
|
||||
|
||||
- it: renders pod-level and container-level securityContext in their own scopes
|
||||
|
|
|
|||
|
|
@ -180,23 +180,14 @@ masterKey:
|
|||
secretKey: master-key
|
||||
|
||||
# Optional: enterprise billable-request metering. When enabled, the gateway and
|
||||
# backend count successful requests to inference, MCP, and A2A endpoints and push
|
||||
# them to LiteLLM's collector over mutual TLS. Both components serve billable
|
||||
# routes: the backend keeps the named-server MCP transport. Requires an
|
||||
# enterprise license. The client certificate identifies the deployment, so it is
|
||||
# mounted read-only from an existing Secret and never passed through the env.
|
||||
# backend count successful requests to inference, MCP, and A2A endpoints. Requires
|
||||
# an enterprise license.
|
||||
billingMetrics:
|
||||
enabled: false
|
||||
endpoint: https://telemetry.litellm.ai # collector to push the counter to
|
||||
# An existing Secret holding the client certificate under tls.crt and its key
|
||||
# under tls.key, usually created from the onboarding artifact. The default is
|
||||
# the conventional name, so the common path is to create that Secret and set
|
||||
# enabled: true. Override only if yours is named differently.
|
||||
secretName: litellm-billing-metrics-mtls
|
||||
# Only for private or test collectors whose server certificate is not on the
|
||||
# public web PKI. The production collector needs no CA override.
|
||||
caSecretName: "" # existing Secret holding ca.crt
|
||||
exportIntervalMs: "" # push cadence; the proxy defaults to 60000
|
||||
endpoint: https://telemetry.litellm.ai
|
||||
tokenUrl: https://litellm-prod.auth.us-west-1.amazoncognito.com/oauth2/token
|
||||
secretName: litellm-billing-metrics
|
||||
exportIntervalMs: ""
|
||||
|
||||
# External Postgres connection.
|
||||
database:
|
||||
|
|
@ -654,70 +645,15 @@ ui:
|
|||
topologySpreadConstraints: []
|
||||
|
||||
lens:
|
||||
library: true
|
||||
|
||||
lensWorker:
|
||||
retainResources: false
|
||||
mode: ""
|
||||
externalUrl: ""
|
||||
externalServiceName: ""
|
||||
gateway:
|
||||
secretName: ""
|
||||
secretKey: gateway-secret
|
||||
standaloneUrl: ""
|
||||
adminTokenSecret:
|
||||
name: ""
|
||||
key: admin-token
|
||||
extraEnv: []
|
||||
extraEnvFrom: []
|
||||
enabled: false
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: ghcr.io/berriai/lens
|
||||
tag: "0.1.0-dev.0"
|
||||
digest: ""
|
||||
pullPolicy: IfNotPresent
|
||||
tokenSecret:
|
||||
url: ""
|
||||
publicUrl: ""
|
||||
service:
|
||||
name: ""
|
||||
key: token
|
||||
port: 4318
|
||||
gatewaySecret:
|
||||
name: ""
|
||||
key: gateway-secret
|
||||
serviceTokenSecret:
|
||||
name: ""
|
||||
key: service-token
|
||||
clickhouse:
|
||||
enabled: true
|
||||
image: clickhouse/clickhouse-server:26.9.6.6@sha256:eb4870e7ca7ed70c259eebfcfbee6cf797017f6b5436c2926bbbfe3d4d28486e
|
||||
storage: 20Gi
|
||||
storageClassName: null
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
clickhouseDatabase: litellm
|
||||
retentionDays: 14
|
||||
clickhouseSecret:
|
||||
name: ""
|
||||
key: url
|
||||
publicUrl: ""
|
||||
service:
|
||||
port: 4318
|
||||
annotations: {}
|
||||
ingress:
|
||||
enabled: false
|
||||
path: /v1/
|
||||
className: ""
|
||||
host: ""
|
||||
annotations: {}
|
||||
tls: []
|
||||
url: ""
|
||||
tmpSizeLimit: 1Gi
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue