* feat(proxy): push-based OTLP billable-request metering for enterprise deployments
Adds opt-in, license-gated metering that counts 2xx HTTP requests to LLM
inference, MCP, and A2A endpoints and exports them over mutual TLS to a global
OpenTelemetry Collector for request-based billing.
A pure ASGI middleware (BillableRequestMetricsMiddleware) classifies each
request by route and records one count per 2xx response via an injected
recorder. The recorder (BillingMetricsRecorder) owns a dedicated OTEL meter
provider and an OTLP/gRPC exporter authenticated with client certificates, kept
isolated from the global meter provider so a customer's own OTEL metrics are
untouched. The recorder is built only when a valid LITELLM_LICENSE is present
and the cert material is configured; otherwise the middleware is a transparent
pass-through.
Deployment identity rides on the mTLS client certificate rather than the
payload, so the secret license key is never sent as an attribute or header; only
the license org id travels as a resource attribute for cross-checking.
Resolves LIT-4089
* fix(proxy): align billable-request metering with the global collector
- switch the exporter to OTLP/HTTP with a TLS client certificate. The
collector front end terminates mutual TLS and validates the client cert
against our CA; server verification uses the system trust store, so the
CA env var is now an optional override for private collectors
- resolve the metrics recorder on the first request via a factory instead
of at import time, so deployments that provide the license and cert env
vars through the YAML config's environment_variables export correctly
- close the metering bypass: classify /images/edits, /images/variations,
/v1/messages, /v1/videos, video remix, /v1/ocr and Gemini generateContent
as billable, and gate LLM routes to POST so GET reads (list videos, fetch
a response) do not bill. Verified live: the collector count matches the
UI usage page successful_requests exactly, with failures excluded on both
sides
* fix(proxy): wrap enterprise billing import in try-except per code-quality gate
The check_unsafe_enterprise_import gate requires every import from an
enterprise-pathed module to be guarded. Annotate the factory with the
middleware's BillingRecorder protocol so no enterprise type import is
needed at type-check time
* chore: satisfy strict lint gates in billing modules
- builtin generics per UP006 (dict/tuple instead of typing.Dict/Tuple)
- noqa the deliberate blind catch that keeps metering from breaking startup
- sort proxy_server import blocks split by the guarded enterprise import
* fix(proxy): bill provider passthrough, search, and rag routes
Route-inventory audit against LiteLLMRoutes.llm_api_routes found more
SpendLogs-producing surfaces the classifier missed: provider passthrough
(/bedrock, /vertex-ai, /cohere and the rest of mapped_pass_through_routes),
/v1/search and vector-store search, and the rag ingest/query routes. All are
counted by the dashboard usage page, so missing them undercounts billing.
The passthrough prefix list is read from LiteLLMRoutes so new providers are
picked up without touching this module. /langfuse is excluded: it forwards
observability traffic and writes no SpendLogs row. Known limitation recorded
in the PR: /v1/realtime is a websocket flow the HTTP middleware does not see
* fix(proxy): bill MCP and A2A requests by protocol transport routes only
The billable-request classifier matched the whole /v1/mcp prefix, so
management and discovery reads such as GET /v1/mcp/tools and GET
/v1/mcp/server counted as billable MCP requests, while real MCP tool
calls on the /{server}/mcp and /toolset/{name}/mcp aliases were missed
because their route handlers rewrite the ASGI scope only after this
middleware has already classified the original path. Classify MCP by the
concrete transport surface (the /mcp streamable-HTTP and SSE sub-app plus
the single-segment server and toolset aliases) and exclude the /v1/mcp
management API. Apply the same shape to A2A, which had the identical
issue: only the /message/send invoke route bills, not /v1/a2a/discover or
the .well-known agent-card reads.
* fix(proxy): harden billable-request classification and recorder lifecycle
Exact-match Anthropic /v1/messages so OpenAI Assistants thread-message
routes no longer bill, add Google Interactions create routes, guard
recorder.record() so a broken exporter can never fail a served request,
lock lazy recorder resolution against concurrent first requests, and
disable metering on empty-string env config instead of accepting a
blank endpoint
* chore(ui): regenerate eslint metrics after staging merge
* docs(proxy): state the lower-bound billing contract in middleware comments
* fix(proxy): bill mcp-rest tool calls and bare a2a agent invokes
POST /mcp-rest/tools/call executes a tool and fires the same MCP spend
logging as the /mcp transport, and POST /a2a/{agent_id} is the JSON-RPC
invoke route whose method (message/send or message/stream) travels in
the body; both returned 2xx without being recorded
* fix(proxy): flush billable-request counts on proxy shutdown
PeriodicExportingMetricReader buffers up to one export interval of
counts; without a final flush every restart silently dropped them. The
factory registers the recorder it builds and proxy_shutdown_event pops
and flushes it, bounded by a 5s timeout so a dead collector cannot
stall shutdown
* fix(proxy): stop billing bare a2a task RPCs and close the shutdown race
POST /a2a/{agent_id} multiplexes JSON-RPC methods off the request body. Only
message/send and message/stream write a SpendLogs row; tasks/get, tasks/cancel
and the pushNotificationConfig RPCs are forwarded upstream and write none.
Classifying the bare path as billable counted those task RPCs and pushed the
metric above the dashboard's successful-request count. Since a path-only
classifier cannot read the body, the bare route no longer bills; the explicit
/message/send routes still do. Missing a bare-path invoke undercounts, which is
the only direction this metric is allowed to drift. The /mcp transport keeps
billing every method because its list path logs a SpendLogs row too.
The billing middleware also sat outside InFlightRequestsMiddleware, and it
records after the inner app returns. A request could therefore be counted as
drained while its record() had not yet run, letting proxy_shutdown_event flush
and stop the exporter underneath it. Registering it before the in-flight
tracker nests it inside, so wait_for_drain covers the record
* test(proxy): stub the OTLP exporter in the recorder-build test
test_premium_with_full_config_builds_recorder built a real MeterProvider, so
the shutdown flush resolved collector.example and opened a TLS connection from
a unit test. The exporter is now stubbed, and a getaddrinfo spy asserts nothing
resolves the collector host so the stub cannot be quietly dropped later
* fix(helm): truncate the helm.sh/chart label to 63 bytes
Kubernetes caps a label value at 63 bytes and .Chart.Version is unbounded. CI
publishes branch builds as 0.0.0-branch-<branch>-<sha>, so helm.sh/chart
rendered as a 64 byte value and the API server rejected every labeled resource
with "must be no more than 63 bytes", including the migrations Job. The
litellm-helm chart already guards this through a litellm.chart helper; this
adds the same helper here.
Swept the rest of the chart for label and name values built from unbounded
input. .Chart.Version appeared only in this label. The remaining candidates all
derive from .Release.Name, which helm itself caps at 53 characters, so they
cannot overflow; three of them are selector labels feeding immutable Deployment
matchLabels, where adding trunc would risk churn for no gain. They are left
alone deliberately.
Verified with a new helm-unittest suite, tests/chart_label_tests.yaml, which
overrides chart.version per test:
helm unittest -f 'tests/*.yaml' helm/litellm # 13 passed
helm unittest -f 'tests/*.yaml' helm/litellm-helm # 54 passed
The truncation cases fail against the previous helper. Reproduced the original
overflow by rendering with the real branch version and measuring the label:
helm template rel helm/litellm -f helm/litellm/tests/values/required.yaml \
| grep helm.sh/chart # 64 bytes before, 63 after
* feat(proxy): accept inline PEM for the billing-metrics mTLS credentials
LITELLM_BILLING_METRICS_CLIENT_CERT, _CLIENT_KEY and _CA_CERT took a filesystem
path. ECS injects Secrets Manager values as environment content and cannot mount
them as files, so a licensed deployment there could not turn metering on.
Each variable now takes either a path or the PEM itself. Inline PEM, detected by
the "-----BEGIN" prefix, is written once when the recorder is built into a 0700
temp dir as a 0600 file, and the config points at that path. The OTLP exporter
still only ever sees paths. A write failure disables metering through the
existing failure-as-None path rather than raising, and path-valued variables are
passed through untouched, so nothing changes for deployments that mount files.
The mixed case works too: mount the CA, inject the client credentials
* feat(helm): add first-class billingMetrics values to the componentized chart
Turning enterprise billable-request metering on meant hand-rolling the env vars
and the cert volume through gateway.extraEnv and gateway.volumes. This adds a
top-level billingMetrics block, off by default, consumed only by the gateway
since that is the component serving billable traffic.
When enabled it renders LITELLM_BILLING_METRICS_ENDPOINT plus the two cert paths
and mounts secretName read-only at /etc/litellm/billing-mtls. caSecretName is
optional and only needed for private collectors whose server certificate is not
on the public web PKI; when set it mounts at /etc/litellm/billing-mtls-ca and
adds the CA env var. exportIntervalMs is passed through only when set.
Enabling without secretName or with an empty endpoint fails the render with a
named message rather than producing a gateway that silently never exports.
The generic gateway.volumes, gateway.volumeMounts and gateway.extraEnv paths are
untouched and still compose with this, so existing overlays keep working.
The chart has no values.schema.json and no README, so there is nothing further to
update. Verified with a new helm-unittest suite:
helm unittest -f 'tests/*.yaml' helm/litellm # 23 passed
helm unittest -f 'tests/*.yaml' helm/litellm-helm # 54 passed
* feat(terraform): billing-metrics variables for the aws and gcp templates
* feat(helm): add billingMetrics values to the classic chart
The componentized chart just gained a first-class billingMetrics block; this
mirrors it in litellm-helm so enabling enterprise billable-request metering no
longer means hand-rolling the env vars and the cert volume through envVars and
volumes.
When enabled the proxy Deployment renders LITELLM_BILLING_METRICS_ENDPOINT plus
the two cert paths, and mounts secretName read-only at /etc/litellm/billing-mtls.
secretName defaults to litellm-billing-metrics-mtls, the conventional name, so
enabling the block is enough once that Secret exists. caSecretName is optional
and only needed for private collectors whose server certificate is not on the
public web PKI; when set it mounts at /etc/litellm/billing-mtls-ca and adds the
CA env var. exportIntervalMs is passed through only when set.
The env entries render after envVars and extraEnvVars, so a user-supplied
LITELLM_BILLING_METRICS_ENDPOINT cannot silently redirect the export under
Kubernetes last-wins duplicate-env semantics; this is the same ordering the
migrations Job relies on for DISABLE_SCHEMA_UPDATE.
Enabling with an emptied secretName or endpoint fails the render with a named
message rather than producing a proxy that silently never exports.
The generic volumes, volumeMounts, envVars and extraEnvVars paths are untouched
and still compose with this, so existing overlays keep working. The chart has no
values.schema.json; README parameters and a setup section are updated.
helm unittest -f 'tests/*.yaml' helm/litellm-helm # 68 passed (54 + 14 new)
helm lint helm/litellm-helm # 0 failed
* test(helm): pin that the migrations job never mounts the billing cert
The componentized chart's suite asserts the backend Deployment stays clear of the
billing wiring, since only the gateway serves billable traffic. The classic chart
has no backend, but it does have a second pod: the migrations Job, which renders
its own env from envVars and extraEnvVars. Nothing today wires the billing
include into it, and nothing stopped a future edit from doing so.
Asserts absence of the env, and that the Job grows no volumes or volumeMounts at
all. Both are notExists rather than notContains because the Job renders neither
key by default, so a notContains would fail on an unknown path instead of
checking the absence it looks like it is checking.
* fix(helm): meter the backend too, it serves the MCP transport
Scoping billingMetrics to the gateway was wrong. Applying each component's own
route allowlist to the proxy app shows the split is 75 billable routes on the
gateway and one on the backend: /{mcp_server_name}/mcp, the named-server MCP
transport, which writes a SpendLogs row on success. Metering only the gateway
would have silently dropped every MCP transport call from the counter, an
undercount proportional to a customer's MCP traffic.
The backend deployment now renders the same env and mounts the same read-only
cert secret. The migrations job still gets neither; it runs prisma and serves no
traffic, and a test pins that.
helm unittest -f 'tests/*.yaml' helm/litellm # 25 passed
helm unittest -f 'tests/*.yaml' helm/litellm-helm # 69 passed
This also aligns the chart with the terraform templates, which inject the
credentials into both components.
* fix(proxy): never log billing credential values when they fail to resolve
Accepting inline PEM turned the cert env vars into secret-bearing values, but
the disable warning still echoed them. A value that is neither a readable path
nor `-----BEGIN`-prefixed PEM, for example a key with a preamble or a malformed
secret, fell through to the path branch and was written to the proxy logs
verbatim, exposing the client certificate or private key to anyone who can read
them.
The warning now names the offending environment variables and tells the operator
what a valid value looks like, without ever printing one
* Revert "fix(helm): truncate the helm.sh/chart label to 63 bytes"
This reverts commit
|
||
|---|---|---|
| .. | ||
| charts | ||
| ci | ||
| templates | ||
| tests | ||
| .helmignore | ||
| Chart.lock | ||
| Chart.yaml | ||
| README.md | ||
| values.yaml | ||
Helm Chart for LiteLLM
Important
This is community maintained, Please make an issue if you run into a bug We recommend using Docker or Kubernetes for production deployments
Prerequisites
- Kubernetes 1.21+
- Helm 3.8.0+
If db.deployStandalone is used:
- PV provisioner support in the underlying infrastructure
If db.useStackgresOperator is used (not yet implemented):
- The Stackgres Operator must already be installed in the Kubernetes Cluster. This chart will not install the operator if it is missing.
Parameters
LiteLLM Proxy Deployment Settings
| Name | Description | Value |
|---|---|---|
replicaCount |
The number of LiteLLM Proxy pods to be deployed | 1 |
masterkeySecretName |
The name of the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use the generated secret name. | N/A |
masterkeySecretKey |
The key within the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use masterkey as the key. |
N/A |
masterkey |
The Master API Key for LiteLLM. If not specified, a random key in the sk-... format is generated. |
N/A |
environmentSecrets |
An optional array of Secret object names. The keys and values in these secrets will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | [] |
environmentConfigMaps |
An optional array of ConfigMap object names. The keys and values in these configmaps will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | [] |
image.repository |
LiteLLM Proxy image repository | docker.litellm.ai/berriai/litellm |
image.pullPolicy |
LiteLLM Proxy image pull policy | IfNotPresent |
image.tag |
Overrides the image tag whose default the latest version of LiteLLM at the time this chart was published. | "" |
imagePullSecrets |
Registry credentials for the LiteLLM and initContainer images. | [] |
serviceAccount.create |
Whether or not to create a Kubernetes Service Account for this deployment. The default is false because LiteLLM has no need to access the Kubernetes API. |
false |
service.type |
Kubernetes Service type (e.g. LoadBalancer, ClusterIP, etc.) |
ClusterIP |
service.port |
TCP port that the Kubernetes Service will listen on. Also the TCP port within the Pod that the proxy will listen on. | 4000 |
livenessProbe.* |
Liveness probe settings for the LiteLLM container (path, periodSeconds, timeoutSeconds, thresholds, and initial delay). |
See values.yaml |
readinessProbe.* |
Readiness probe settings for the LiteLLM container (path, periodSeconds, timeoutSeconds, thresholds, and initial delay). |
See values.yaml |
startupProbe.* |
Startup probe settings for the LiteLLM container (path, periodSeconds, timeoutSeconds, thresholds, and initial delay). |
See values.yaml |
resources.* |
CPU/memory requests and limits for the LiteLLM container. | {} |
service.loadBalancerClass |
Optional LoadBalancer implementation class (only used when service.type is LoadBalancer) |
"" |
ingress.labels |
Additional labels for the Ingress resource | {} |
ingress.* |
See values.yaml for example settings | N/A |
proxyConfigMap.create |
When true, render a ConfigMap from .Values.proxy_config and mount it. |
true |
proxyConfigMap.name |
When create=false, name of the existing ConfigMap to mount. |
"" |
proxyConfigMap.key |
Key in the ConfigMap that contains the proxy config file. | "config.yaml" |
proxy_config.* |
See values.yaml for default settings. Rendered into the ConfigMap’s config.yaml only when proxyConfigMap.create=true. See example_config_yaml for configuration examples. |
N/A |
extraContainers[] |
An array of additional containers to be deployed as sidecars alongside the LiteLLM Proxy. | |
pdb.enabled |
Enable a PodDisruptionBudget for the LiteLLM proxy Deployment | false |
pdb.minAvailable |
Minimum number/percentage of pods that must be available during voluntary disruptions (choose one of minAvailable/maxUnavailable) | null |
pdb.maxUnavailable |
Maximum number/percentage of pods that can be unavailable during voluntary disruptions (choose one of minAvailable/maxUnavailable) | null |
pdb.annotations |
Extra metadata annotations to add to the PDB | {} |
pdb.labels |
Extra metadata labels to add to the PDB | {} |
| 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.exportIntervalMs | How often the counter is pushed, in milliseconds. The proxy defaults to 60000 when unset. | "" |
Example proxy_config ConfigMap from values (default):
proxyConfigMap:
create: true
key: "config.yaml"
proxy_config:
general_settings:
master_key: os.environ/PROXY_MASTER_KEY
model_list:
- model_name: gpt-3.5-turbo
litellm_params:
model: gpt-3.5-turbo
api_key: eXaMpLeOnLy
Example using existing proxyConfigMap instead of creating it:
proxyConfigMap:
create: false
name: my-litellm-config
key: config.yaml
# proxy_config is ignored in this mode
Example environmentSecrets Secret
apiVersion: v1
kind: Secret
metadata:
name: litellm-envsecrets
data:
AZURE_OPENAI_API_KEY: TXlTZWN1cmVLM3k=
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:
kubectl create secret tls litellm-billing-metrics-mtls --cert=client.crt --key=client.key
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.
Database Settings
| Name | Description | Value |
|---|---|---|
db.useExisting |
Use an existing Postgres database. A Kubernetes Secret object must exist that contains credentials for connecting to the database. An example secret object definition is provided below. | false |
db.endpoint |
If db.useExisting is true, this is the IP, Hostname or Service Name of the Postgres server to connect to. |
localhost |
db.database |
If db.useExisting is true, the name of the existing database to connect to. |
litellm |
db.url |
If db.useExisting is true, the connection url of the existing database to connect to can be overwritten with this value. |
postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST)/$(DATABASE_NAME) |
db.secret.name |
If db.useExisting is true, the name of the Kubernetes Secret that contains credentials. |
postgres |
db.secret.usernameKey |
If db.useExisting is true, the name of the key within the Kubernetes Secret that holds the username for authenticating with the Postgres instance. |
username |
db.secret.passwordKey |
If db.useExisting is true, the name of the key within the Kubernetes Secret that holds the password associates with the above user. |
password |
db.useStackgresOperator |
Not yet implemented. | false |
db.deployStandalone |
Deploy a standalone, single instance deployment of Postgres, using the Bitnami postgresql chart. This is useful for getting started but doesn't provide HA or (by default) data backups. | true |
postgresql.* |
If db.deployStandalone is true, configuration passed to the Bitnami postgresql chart. See the Bitnami Documentation for full configuration details. See values.yaml for the default configuration. |
See values.yaml |
postgresql.auth.* |
If db.deployStandalone is true, care should be taken to ensure the default password and postgres-password values are NOT used. |
NoTaGrEaTpAsSwOrD |
Example Postgres db.useExisting Secret
apiVersion: v1
kind: Secret
metadata:
name: postgres
data:
# Password for the "postgres" user
postgres-password: <some secure password, base64 encoded>
username: litellm
password: <some secure password, base64 encoded>
type: Opaque
Examples for environmentSecrets and environemntConfigMaps
# Use config map for not-secret configuration data
apiVersion: v1
kind: ConfigMap
metadata:
name: litellm-env-configmap
data:
SOME_KEY: someValue
ANOTHER_KEY: anotherValue
# Use secrets for things which are actually secret like API keys, credentials, etc
# Base64 encode the values stored in a Kubernetes Secret: $ pbpaste | base64 | pbcopy
# The --decode flag is convenient: $ pbpaste | base64 --decode
apiVersion: v1
kind: Secret
metadata:
name: litellm-env-secret
type: Opaque
data:
SOME_PASSWORD: cDZbUGVXeU5e0ZW # base64 encoded
ANOTHER_PASSWORD: AAZbUGVXeU5e0ZB # base64 encoded
Source: GitHub Gist from troyharvey
Migration Job Settings
The migration job supports both ArgoCD and Helm hooks to ensure database migrations run at the appropriate time during deployments.
| Name | Description | Value |
|---|---|---|
migrationJob.enabled |
Enable or disable the schema migration Job | true |
migrationJob.backoffLimit |
Backoff limit for Job restarts | 4 |
migrationJob.ttlSecondsAfterFinished |
TTL for completed migration jobs | 120 |
migrationJob.annotations |
Additional annotations for the migration job pod | {} |
migrationJob.extraContainers |
Additional containers to run alongside the migration job | [] |
migrationJob.hooks.argocd.enabled |
Enable ArgoCD hooks for the migration job (uses PreSync hook with BeforeHookCreation delete policy) | true |
migrationJob.hooks.helm.enabled |
Enable Helm hooks for the migration job (uses pre-install,pre-upgrade hooks with before-hook-creation delete policy) | false |
migrationJob.hooks.helm.weight |
Helm hook execution order (lower weights executed first). Optional - defaults to "1" if not specified. | N/A |
Accessing the Admin UI
When browsing to the URL published per the settings in ingress.*, you will
be prompted for Admin Configuration. The Proxy Endpoint is the internal
(from the litellm pod's perspective) URL published by the <RELEASE>-litellm
Kubernetes Service. If the deployment uses the default settings for this
service, the Proxy Endpoint should be set to http://<RELEASE>-litellm:4000.
The Proxy Key is the value specified for masterkey or, if a masterkey
was not provided to the helm command line, the masterkey is a randomly
generated string in the sk-... format stored in the <RELEASE>-litellm-masterkey Kubernetes Secret.
kubectl -n litellm get secret <RELEASE>-litellm-masterkey -o jsonpath="{.data.masterkey}"
Admin UI Limitations
At the time of writing, the Admin UI is unable to add models. This is because
it would need to update the config.yaml file which is a exposed ConfigMap, and
therefore, read-only. This is a limitation of this helm chart, not the Admin UI
itself.