From 8c20b5532ba3d0259dde1f1749ec2e94162c5ee8 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Mon, 29 Jun 2026 12:15:35 +0300 Subject: [PATCH 01/29] 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 --- litellm/proxy/enterprise_billing/__init__.py | 0 .../enterprise_billing/billing_metrics.py | 183 +++++++++++++++++ .../billable_request_metrics_middleware.py | 113 +++++++++++ litellm/proxy/proxy_server.py | 14 ++ .../test_billing_metrics.py | 183 +++++++++++++++++ ...est_billable_request_metrics_middleware.py | 190 ++++++++++++++++++ 6 files changed, 683 insertions(+) create mode 100644 litellm/proxy/enterprise_billing/__init__.py create mode 100644 litellm/proxy/enterprise_billing/billing_metrics.py create mode 100644 litellm/proxy/middleware/billable_request_metrics_middleware.py create mode 100644 tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py create mode 100644 tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py diff --git a/litellm/proxy/enterprise_billing/__init__.py b/litellm/proxy/enterprise_billing/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/litellm/proxy/enterprise_billing/billing_metrics.py b/litellm/proxy/enterprise_billing/billing_metrics.py new file mode 100644 index 000000000000..bbbf7b57eda5 --- /dev/null +++ b/litellm/proxy/enterprise_billing/billing_metrics.py @@ -0,0 +1,183 @@ +""" +Push-based OTLP metering for enterprise litellm deployments. + +Owns a dedicated OpenTelemetry meter provider and an OTLP/gRPC exporter +authenticated to our global collector with mutual TLS. It is intentionally +isolated from the global meter provider so the customer's own OTEL metrics are +untouched and ours never leak into their backend. + +The deployment's identity rides on the mTLS client certificate, not on the +payload; the secret license key is never sent as an attribute or header. +""" + +import os +from dataclasses import dataclass +from typing import TYPE_CHECKING, Dict, Optional, Union + +import grpc +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry.metrics import Counter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import Resource + +from litellm._logging import verbose_proxy_logger +from litellm.proxy.middleware.billable_request_metrics_middleware import BillableCategory + +if TYPE_CHECKING: + from litellm.proxy._types import EnterpriseLicenseData + +ENDPOINT_ENV = "LITELLM_BILLING_METRICS_ENDPOINT" +CLIENT_CERT_ENV = "LITELLM_BILLING_METRICS_CLIENT_CERT" +CLIENT_KEY_ENV = "LITELLM_BILLING_METRICS_CLIENT_KEY" +CA_CERT_ENV = "LITELLM_BILLING_METRICS_CA_CERT" +EXPORT_INTERVAL_ENV = "LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS" +DEFAULT_EXPORT_INTERVAL_MS = 60_000 + +METRIC_NAME = "litellm.enterprise.billable_requests" +METER_NAME = "litellm.enterprise.billing" + +AttributeValue = Union[str, int] + + +@dataclass(frozen=True, slots=True) +class BillingMetricsConfig: + endpoint: str + client_cert_path: str + client_key_path: str + ca_cert_path: str + export_interval_ms: int + litellm_version: str + license_id: Optional[str] + + +def _read_bytes(path: str) -> bytes: + with open(path, "rb") as handle: + return handle.read() + + +def _mtls_credentials_args(config: BillingMetricsConfig) -> Dict[str, bytes]: + """Map cert files to grpc.ssl_channel_credentials kwargs: CA verifies the server, cert+key authenticate us.""" + return { + "root_certificates": _read_bytes(config.ca_cert_path), + "private_key": _read_bytes(config.client_key_path), + "certificate_chain": _read_bytes(config.client_cert_path), + } + + +def _resource_attributes(config: BillingMetricsConfig) -> Dict[str, AttributeValue]: + base: Dict[str, AttributeValue] = { + "service.name": "litellm-proxy", + "litellm.version": config.litellm_version, + } + license_attr: Dict[str, AttributeValue] = {"litellm.license.id": config.license_id} if config.license_id else {} + return {**base, **license_attr} + + +def _billable_attributes( + category: BillableCategory, route: str, status_code: int, model_id: Optional[str] +) -> Dict[str, AttributeValue]: + base: Dict[str, AttributeValue] = { + "litellm.endpoint.category": category.value, + "http.route": route, + "http.response.status_code": status_code, + } + model_attr: Dict[str, AttributeValue] = {"litellm.model_id": model_id} if model_id else {} + return {**base, **model_attr} + + +def build_mtls_meter_provider(config: BillingMetricsConfig) -> MeterProvider: + credentials: grpc.ChannelCredentials = grpc.ssl_channel_credentials(**_mtls_credentials_args(config)) + exporter = OTLPMetricExporter(endpoint=config.endpoint, credentials=credentials) + reader = PeriodicExportingMetricReader(exporter, export_interval_millis=config.export_interval_ms) + return MeterProvider(metric_readers=[reader], resource=Resource.create(_resource_attributes(config))) + + +class BillingMetricsRecorder: + """Increments one OTLP counter per billable request. The meter provider is injected (see the factory).""" + + def __init__(self, provider: MeterProvider) -> None: + self._provider = provider + self._counter: Counter = provider.get_meter(METER_NAME).create_counter( + name=METRIC_NAME, + unit="{request}", + description="Count of 2xx HTTP requests to billable LLM/MCP/A2A endpoints", + ) + + def record(self, *, category: BillableCategory, route: str, status_code: int, model_id: Optional[str]) -> None: + self._counter.add(1, _billable_attributes(category, route, status_code, model_id)) + + +def _export_interval_ms() -> int: + raw = os.getenv(EXPORT_INTERVAL_ENV) + if raw is None: + return DEFAULT_EXPORT_INTERVAL_MS + try: + return int(raw) + except ValueError: + verbose_proxy_logger.warning( + "Invalid %s=%r, falling back to %d ms", EXPORT_INTERVAL_ENV, raw, DEFAULT_EXPORT_INTERVAL_MS + ) + return DEFAULT_EXPORT_INTERVAL_MS + + +def load_billing_metrics_config( + *, license_data: Optional["EnterpriseLicenseData"], litellm_version: str +) -> Optional[BillingMetricsConfig]: + endpoint = os.getenv(ENDPOINT_ENV) + client_cert = os.getenv(CLIENT_CERT_ENV) + client_key = os.getenv(CLIENT_KEY_ENV) + ca_cert = os.getenv(CA_CERT_ENV) + + missing = [ + name + for name, value in ( + (ENDPOINT_ENV, endpoint), + (CLIENT_CERT_ENV, client_cert), + (CLIENT_KEY_ENV, client_key), + (CA_CERT_ENV, ca_cert), + ) + if not value + ] + if endpoint is None or client_cert is None or client_key is None or ca_cert is None: + verbose_proxy_logger.warning( + "Enterprise billing metrics disabled: licensed deployment missing config (%s)", + ", ".join(missing), + ) + return None + + unreadable = [path for path in (client_cert, client_key, ca_cert) if not os.path.isfile(path)] + if unreadable: + verbose_proxy_logger.warning( + "Enterprise billing metrics disabled: certificate file(s) not found: %s", + ", ".join(unreadable), + ) + return None + + return BillingMetricsConfig( + endpoint=endpoint, + client_cert_path=client_cert, + client_key_path=client_key, + ca_cert_path=ca_cert, + export_interval_ms=_export_interval_ms(), + litellm_version=litellm_version, + license_id=(license_data or {}).get("user_id"), + ) + + +def build_billing_metrics_recorder( + *, premium: bool, license_data: Optional["EnterpriseLicenseData"], litellm_version: str +) -> Optional[BillingMetricsRecorder]: + """Build the recorder, or None when the deployment is not licensed or metering is unconfigured.""" + if not premium: + return None + + config = load_billing_metrics_config(license_data=license_data, litellm_version=litellm_version) + if config is None: + return None + + try: + return BillingMetricsRecorder(build_mtls_meter_provider(config)) + except Exception as exc: + verbose_proxy_logger.warning("Enterprise billing metrics disabled: failed to initialize exporter: %s", exc) + return None diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py new file mode 100644 index 000000000000..82bbe7611bf4 --- /dev/null +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -0,0 +1,113 @@ +""" +Counts billable HTTP requests on enterprise deployments. + +A billable request is an inbound request to an LLM inference, MCP, or A2A +endpoint that returns a 2xx status. The actual export happens in an injected +recorder (see litellm.proxy.enterprise_billing.billing_metrics); when no +recorder is injected (non-enterprise, or metering misconfigured) this +middleware is a transparent pass-through. +""" + +from enum import Enum +from typing import Optional, Protocol, Sequence, Tuple, runtime_checkable + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + + +class BillableCategory(str, Enum): + LLM = "llm" + MCP = "mcp" + A2A = "a2a" + + +@runtime_checkable +class BillingRecorder(Protocol): + def record(self, *, category: BillableCategory, route: str, status_code: int, model_id: Optional[str]) -> None: ... + + +_MODEL_ID_HEADER = b"x-litellm-model-id" + +# Ordered: a longer suffix that shares an ending with a shorter one must come +# first, e.g. "/chat/completions" before "/completions". +_LLM_ROUTE_SUFFIXES: Tuple[str, ...] = ( + "/chat/completions", + "/completions", + "/embeddings", + "/responses", + "/rerank", + "/moderations", + "/images/generations", + "/audio/transcriptions", + "/audio/translations", + "/audio/speech", +) + + +def _classify_llm_route(path: str) -> Optional[str]: + return next((suffix for suffix in _LLM_ROUTE_SUFFIXES if path == suffix or path.endswith(suffix)), None) + + +def classify_billable_request(path: str) -> Optional[Tuple[BillableCategory, str]]: + """Map a request path to its (category, normalized route), or None if not billable.""" + normalized = path.rstrip("/") or "/" + + if normalized == "/mcp" or normalized.startswith("/mcp/"): + return (BillableCategory.MCP, "/mcp") + if normalized == "/v1/mcp" or normalized.startswith("/v1/mcp/"): + return (BillableCategory.MCP, "/v1/mcp") + if normalized == "/v1/a2a" or normalized.startswith("/v1/a2a/"): + return (BillableCategory.A2A, "/v1/a2a") + if normalized == "/a2a" or normalized.startswith("/a2a/"): + return (BillableCategory.A2A, "/a2a") + + llm_route = _classify_llm_route(normalized) + if llm_route is not None: + return (BillableCategory.LLM, llm_route) + return None + + +def _extract_model_id(headers: Sequence[Tuple[bytes, bytes]]) -> Optional[str]: + return next( + (value.decode("latin-1") for name, value in headers if name.lower() == _MODEL_ID_HEADER and value), + None, + ) + + +class BillableRequestMetricsMiddleware: + """ + Pure ASGI middleware that records one billable request per 2xx response to a + billable endpoint. Modeled on InFlightRequestsMiddleware: it wraps `send`, + reads the final status and the x-litellm-model-id header off the + `http.response.start` message, and never blocks or fails the request path. + """ + + def __init__(self, app: ASGIApp, recorder: Optional[BillingRecorder] = None) -> None: + self.app = app + self.recorder = recorder + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + recorder = self.recorder + if recorder is None or scope["type"] != "http": + await self.app(scope, receive, send) + return + + classification = classify_billable_request(scope.get("path", "")) + if classification is None: + await self.app(scope, receive, send) + return + + category, route = classification + status_code = 0 + model_id: Optional[str] = None + + async def send_wrapper(message: Message) -> None: + nonlocal status_code, model_id + if message["type"] == "http.response.start": + status_code = message["status"] + model_id = _extract_model_id(message.get("headers", [])) + await send(message) + + await self.app(scope, receive, send_wrapper) + + if 200 <= status_code < 300: + recorder.record(category=category, route=route, status_code=status_code, model_id=model_id) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c619133cebdd..c27f419490f8 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -436,6 +436,12 @@ def generate_feedback_box(): router as plugin_router, register_plugins_from_config, ) +from litellm.proxy.enterprise_billing.billing_metrics import ( + build_billing_metrics_recorder, +) +from litellm.proxy.middleware.billable_request_metrics_middleware import ( + BillableRequestMetricsMiddleware, +) from litellm.proxy.middleware.in_flight_requests_middleware import ( InFlightRequestsMiddleware, ) @@ -1805,6 +1811,14 @@ def _restructure_ui_html_files(ui_root: str) -> None: app.add_middleware(PrometheusAuthMiddleware) app.add_middleware(InFlightRequestsMiddleware) app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware( + BillableRequestMetricsMiddleware, + recorder=build_billing_metrics_recorder( + premium=premium_user, + license_data=premium_user_data, + litellm_version=version, + ), +) def mount_swagger_ui(): diff --git a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py new file mode 100644 index 000000000000..2ccd0921bf56 --- /dev/null +++ b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py @@ -0,0 +1,183 @@ +""" +Tests for the enterprise billing-metrics recorder and its factory. + +These verify the license gate, the missing-config and missing-cert disable +paths, the cert-file -> mTLS credential mapping (CA verifies the server, +cert+key authenticate us), the metric attribute mapping, and that recording +produces the expected OTLP counter via an in-memory reader. +""" + +from pathlib import Path +from typing import Dict, Optional + +import pytest +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from litellm.proxy.enterprise_billing import billing_metrics as bm +from litellm.proxy.middleware.billable_request_metrics_middleware import BillableCategory + +_ENV_VARS = ( + bm.ENDPOINT_ENV, + bm.CLIENT_CERT_ENV, + bm.CLIENT_KEY_ENV, + bm.CA_CERT_ENV, + bm.EXPORT_INTERVAL_ENV, +) + + +@pytest.fixture(autouse=True) +def clear_env(monkeypatch): + for name in _ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def _write_certs(tmp_path: Path) -> Dict[str, str]: + files = { + bm.CA_CERT_ENV: ("ca.pem", b"ca-bytes"), + bm.CLIENT_CERT_ENV: ("client.pem", b"client-cert-bytes"), + bm.CLIENT_KEY_ENV: ("client.key", b"client-key-bytes"), + } + paths = {} + for env_name, (filename, content) in files.items(): + path = tmp_path / filename + path.write_bytes(content) + paths[env_name] = str(path) + return paths + + +def _set_full_env(monkeypatch, tmp_path: Path) -> Dict[str, str]: + paths = _write_certs(tmp_path) + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CA_CERT_ENV, paths[bm.CA_CERT_ENV]) + monkeypatch.setenv(bm.CLIENT_CERT_ENV, paths[bm.CLIENT_CERT_ENV]) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, paths[bm.CLIENT_KEY_ENV]) + return paths + + +def _config(tmp_path: Path, license_id: Optional[str] = "org-1") -> bm.BillingMetricsConfig: + paths = _write_certs(tmp_path) + return bm.BillingMetricsConfig( + endpoint="https://collector.example:4317", + client_cert_path=paths[bm.CLIENT_CERT_ENV], + client_key_path=paths[bm.CLIENT_KEY_ENV], + ca_cert_path=paths[bm.CA_CERT_ENV], + export_interval_ms=60_000, + litellm_version="1.2.3", + license_id=license_id, + ) + + +# ── Factory gating ──────────────────────────────────────────────────────────── + + +def test_not_premium_returns_none(tmp_path, monkeypatch): + _set_full_env(monkeypatch, tmp_path) + assert bm.build_billing_metrics_recorder(premium=False, license_data=None, litellm_version="1.0") is None + + +def test_premium_without_config_returns_none(monkeypatch): + assert bm.build_billing_metrics_recorder(premium=True, license_data={"user_id": "x"}, litellm_version="1.0") is None + + +def test_premium_with_missing_cert_files_returns_none(monkeypatch, tmp_path): + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CA_CERT_ENV, str(tmp_path / "missing-ca.pem")) + monkeypatch.setenv(bm.CLIENT_CERT_ENV, str(tmp_path / "missing-cert.pem")) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, str(tmp_path / "missing-key.pem")) + assert bm.build_billing_metrics_recorder(premium=True, license_data=None, litellm_version="1.0") is None + + +def test_premium_with_full_config_builds_recorder(monkeypatch, tmp_path): + _set_full_env(monkeypatch, tmp_path) + recorder = bm.build_billing_metrics_recorder( + premium=True, license_data={"user_id": "org-1"}, litellm_version="1.0" + ) + assert isinstance(recorder, bm.BillingMetricsRecorder) + + +# ── Config loading ──────────────────────────────────────────────────────────── + + +def test_load_config_carries_license_id(monkeypatch, tmp_path): + _set_full_env(monkeypatch, tmp_path) + config = bm.load_billing_metrics_config(license_data={"user_id": "org-42"}, litellm_version="9.9") + assert config is not None and config.license_id == "org-42" and config.litellm_version == "9.9" + + +def test_export_interval_default_and_override(monkeypatch): + assert bm._export_interval_ms() == bm.DEFAULT_EXPORT_INTERVAL_MS + monkeypatch.setenv(bm.EXPORT_INTERVAL_ENV, "5000") + assert bm._export_interval_ms() == 5000 + monkeypatch.setenv(bm.EXPORT_INTERVAL_ENV, "not-a-number") + assert bm._export_interval_ms() == bm.DEFAULT_EXPORT_INTERVAL_MS + + +# ── mTLS credential mapping ─────────────────────────────────────────────────── + + +def test_mtls_credentials_args_map_files_correctly(tmp_path): + """CA -> root_certificates (verify server); client cert/key -> certificate_chain/private_key (authenticate us).""" + args = bm._mtls_credentials_args(_config(tmp_path)) + assert args == { + "root_certificates": b"ca-bytes", + "private_key": b"client-key-bytes", + "certificate_chain": b"client-cert-bytes", + } + + +# ── Resource and metric attributes ──────────────────────────────────────────── + + +def test_resource_attributes_include_license_id(tmp_path): + attrs = bm._resource_attributes(_config(tmp_path, license_id="org-7")) + assert attrs["service.name"] == "litellm-proxy" + assert attrs["litellm.version"] == "1.2.3" + assert attrs["litellm.license.id"] == "org-7" + + +def test_resource_attributes_omit_license_id_when_absent(tmp_path): + attrs = bm._resource_attributes(_config(tmp_path, license_id=None)) + assert "litellm.license.id" not in attrs + + +def test_billable_attributes_with_model_id(): + attrs = bm._billable_attributes(BillableCategory.LLM, "/chat/completions", 200, "deploy-3") + assert attrs == { + "litellm.endpoint.category": "llm", + "http.route": "/chat/completions", + "http.response.status_code": 200, + "litellm.model_id": "deploy-3", + } + + +def test_billable_attributes_omit_model_id_when_none(): + attrs = bm._billable_attributes(BillableCategory.MCP, "/mcp", 200, None) + assert "litellm.model_id" not in attrs + + +# ── End-to-end recording via in-memory reader ───────────────────────────────── + + +def _counter_points(reader: InMemoryMetricReader): + data = reader.get_metrics_data() + for resource_metric in data.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + if metric.name == bm.METRIC_NAME: + return list(metric.data.data_points) + return [] + + +def test_record_increments_counter_with_attributes(): + reader = InMemoryMetricReader() + recorder = bm.BillingMetricsRecorder(MeterProvider(metric_readers=[reader])) + + recorder.record(category=BillableCategory.LLM, route="/chat/completions", status_code=200, model_id="m1") + recorder.record(category=BillableCategory.LLM, route="/chat/completions", status_code=200, model_id="m1") + recorder.record(category=BillableCategory.MCP, route="/mcp", status_code=200, model_id=None) + + points = _counter_points(reader) + by_category = {point.attributes["litellm.endpoint.category"]: point.value for point in points} + assert by_category["llm"] == 2 + assert by_category["mcp"] == 1 diff --git a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py new file mode 100644 index 000000000000..6525caf3d642 --- /dev/null +++ b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py @@ -0,0 +1,190 @@ +""" +Tests for BillableRequestMetricsMiddleware and route classification. + +These verify the metering gate (records only on 2xx to a billable endpoint), +correct category/route classification, model-id extraction, and that the +middleware is a transparent pass-through when no recorder is injected. +""" + +import asyncio +from typing import List, Optional, Tuple + +import pytest +from starlette.applications import Starlette +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route +from starlette.testclient import TestClient + +from litellm.proxy.middleware.billable_request_metrics_middleware import ( + BillableCategory, + BillableRequestMetricsMiddleware, + _extract_model_id, + classify_billable_request, +) + + +class FakeRecorder: + def __init__(self) -> None: + self.calls: List[dict] = [] + + def record(self, *, category: BillableCategory, route: str, status_code: int, model_id: Optional[str]) -> None: + self.calls.append( + {"category": category, "route": route, "status_code": status_code, "model_id": model_id} + ) + + +def _make_app(recorder: Optional[FakeRecorder], status_code: int = 200, model_id: Optional[str] = None) -> Starlette: + async def handler(request: Request) -> Response: + headers = {"x-litellm-model-id": model_id} if model_id else {} + return JSONResponse({}, status_code=status_code, headers=headers) + + paths = [ + "/v1/chat/completions", + "/chat/completions", + "/v1/embeddings", + "/v1/completions", + "/mcp", + "/v1/mcp/tools", + "/a2a/agent-1/message/send", + "/v1/a2a/discover", + "/health", + "/ui", + ] + app = Starlette(routes=[Route(p, handler, methods=["GET", "POST"]) for p in paths]) + app.add_middleware(BillableRequestMetricsMiddleware, recorder=recorder) + return app + + +# ── Structure ─────────────────────────────────────────────────────────────── + + +def test_is_pure_asgi_not_base_http_middleware(): + assert not issubclass(BillableRequestMetricsMiddleware, BaseHTTPMiddleware) + + +# ── classify_billable_request ───────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "path,expected", + [ + ("/v1/chat/completions", (BillableCategory.LLM, "/chat/completions")), + ("/chat/completions", (BillableCategory.LLM, "/chat/completions")), + ("/openai/deployments/gpt-4o/chat/completions", (BillableCategory.LLM, "/chat/completions")), + ("/engines/gpt-4o/chat/completions", (BillableCategory.LLM, "/chat/completions")), + ("/v1/completions", (BillableCategory.LLM, "/completions")), + ("/completions", (BillableCategory.LLM, "/completions")), + ("/v1/embeddings", (BillableCategory.LLM, "/embeddings")), + ("/v1/responses", (BillableCategory.LLM, "/responses")), + ("/v1/rerank", (BillableCategory.LLM, "/rerank")), + ("/v1/audio/transcriptions", (BillableCategory.LLM, "/audio/transcriptions")), + ("/mcp", (BillableCategory.MCP, "/mcp")), + ("/mcp/", (BillableCategory.MCP, "/mcp")), + ("/mcp/tools/list", (BillableCategory.MCP, "/mcp")), + ("/v1/mcp", (BillableCategory.MCP, "/v1/mcp")), + ("/v1/mcp/servers", (BillableCategory.MCP, "/v1/mcp")), + ("/a2a", (BillableCategory.A2A, "/a2a")), + ("/a2a/agent-1/message/send", (BillableCategory.A2A, "/a2a")), + ("/v1/a2a", (BillableCategory.A2A, "/v1/a2a")), + ("/v1/a2a/discover", (BillableCategory.A2A, "/v1/a2a")), + ], +) +def test_classify_billable(path: str, expected: Tuple[BillableCategory, str]): + assert classify_billable_request(path) == expected + + +@pytest.mark.parametrize( + "path", + ["/health", "/health/readiness", "/metrics", "/ui", "/", "/v1/models", "/key/generate", "/v1/files"], +) +def test_classify_non_billable_returns_none(path: str): + assert classify_billable_request(path) is None + + +def test_chat_completions_not_misclassified_as_plain_completions(): + """The /chat/completions suffix must win over /completions so the route label is correct.""" + category, route = classify_billable_request("/v1/chat/completions") + assert route == "/chat/completions" + + +# ── _extract_model_id ───────────────────────────────────────────────────────── + + +def test_extract_model_id_present(): + headers = [(b"content-type", b"application/json"), (b"x-litellm-model-id", b"deploy-123")] + assert _extract_model_id(headers) == "deploy-123" + + +def test_extract_model_id_case_insensitive(): + assert _extract_model_id([(b"X-LiteLLM-Model-Id", b"deploy-9")]) == "deploy-9" + + +def test_extract_model_id_absent(): + assert _extract_model_id([(b"content-type", b"application/json")]) is None + + +# ── Middleware recording behaviour ──────────────────────────────────────────── + + +def test_records_once_on_2xx_llm_with_model_id(): + recorder = FakeRecorder() + TestClient(_make_app(recorder, status_code=200, model_id="deploy-7")).post("/v1/chat/completions") + assert recorder.calls == [ + {"category": BillableCategory.LLM, "route": "/chat/completions", "status_code": 200, "model_id": "deploy-7"} + ] + + +def test_records_mcp_category(): + recorder = FakeRecorder() + TestClient(_make_app(recorder)).post("/v1/mcp/tools") + assert len(recorder.calls) == 1 and recorder.calls[0]["category"] == BillableCategory.MCP + + +def test_records_a2a_category(): + recorder = FakeRecorder() + TestClient(_make_app(recorder)).post("/a2a/agent-1/message/send") + assert len(recorder.calls) == 1 and recorder.calls[0]["category"] == BillableCategory.A2A + + +def test_does_not_record_on_4xx(): + recorder = FakeRecorder() + TestClient(_make_app(recorder, status_code=404)).post("/v1/chat/completions") + assert recorder.calls == [] + + +def test_does_not_record_on_5xx(): + recorder = FakeRecorder() + TestClient(_make_app(recorder, status_code=503)).post("/v1/chat/completions") + assert recorder.calls == [] + + +def test_does_not_record_non_billable_path(): + recorder = FakeRecorder() + TestClient(_make_app(recorder, status_code=200)).get("/health") + assert recorder.calls == [] + + +def test_no_model_id_when_header_absent(): + recorder = FakeRecorder() + TestClient(_make_app(recorder, status_code=200, model_id=None)).post("/v1/mcp/tools") + assert recorder.calls[0]["model_id"] is None + + +def test_passthrough_when_recorder_is_none(): + """Non-enterprise: middleware records nothing and does not break the response.""" + response = TestClient(_make_app(None, status_code=200)).post("/v1/chat/completions") + assert response.status_code == 200 + + +def test_non_http_scope_is_ignored(): + recorder = FakeRecorder() + + class _Inner: + async def __call__(self, scope, receive, send): + return None + + mw = BillableRequestMetricsMiddleware(_Inner(), recorder=recorder) + asyncio.run(mw({"type": "lifespan"}, None, None)) # type: ignore[arg-type] + assert recorder.calls == [] From fb1c18b52a8addf20a682a9b471924ca819b5790 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Tue, 7 Jul 2026 16:05:31 +0300 Subject: [PATCH 02/29] 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 --- .../enterprise_billing/billing_metrics.py | 61 +++++++----- .../billable_request_metrics_middleware.py | 55 +++++++++-- litellm/proxy/proxy_server.py | 12 ++- .../test_billing_metrics.py | 63 +++++++++--- ...est_billable_request_metrics_middleware.py | 97 ++++++++++++++++++- 5 files changed, 240 insertions(+), 48 deletions(-) diff --git a/litellm/proxy/enterprise_billing/billing_metrics.py b/litellm/proxy/enterprise_billing/billing_metrics.py index bbbf7b57eda5..8ef6d7027a98 100644 --- a/litellm/proxy/enterprise_billing/billing_metrics.py +++ b/litellm/proxy/enterprise_billing/billing_metrics.py @@ -1,12 +1,15 @@ """ Push-based OTLP metering for enterprise litellm deployments. -Owns a dedicated OpenTelemetry meter provider and an OTLP/gRPC exporter -authenticated to our global collector with mutual TLS. It is intentionally -isolated from the global meter provider so the customer's own OTEL metrics are -untouched and ours never leak into their backend. - -The deployment's identity rides on the mTLS client certificate, not on the +Owns a dedicated OpenTelemetry meter provider and an OTLP/HTTP exporter +authenticated to our global collector with a TLS client certificate. The +collector front end terminates mutual TLS: the client certificate presented +here is validated against our CA at the edge, and the verified subject is +what identifies the deployment. It is intentionally isolated from the global +meter provider so the customer's own OTEL metrics are untouched and ours +never leak into their backend. + +The deployment's identity rides on the TLS client certificate, not on the payload; the secret license key is never sent as an attribute or header. """ @@ -14,8 +17,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Dict, Optional, Union -import grpc -from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter from opentelemetry.metrics import Counter from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader @@ -33,6 +35,7 @@ CA_CERT_ENV = "LITELLM_BILLING_METRICS_CA_CERT" EXPORT_INTERVAL_ENV = "LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS" DEFAULT_EXPORT_INTERVAL_MS = 60_000 +_METRICS_PATH = "/v1/metrics" METRIC_NAME = "litellm.enterprise.billable_requests" METER_NAME = "litellm.enterprise.billing" @@ -45,24 +48,16 @@ class BillingMetricsConfig: endpoint: str client_cert_path: str client_key_path: str - ca_cert_path: str + ca_cert_path: Optional[str] export_interval_ms: int litellm_version: str license_id: Optional[str] -def _read_bytes(path: str) -> bytes: - with open(path, "rb") as handle: - return handle.read() - - -def _mtls_credentials_args(config: BillingMetricsConfig) -> Dict[str, bytes]: - """Map cert files to grpc.ssl_channel_credentials kwargs: CA verifies the server, cert+key authenticate us.""" - return { - "root_certificates": _read_bytes(config.ca_cert_path), - "private_key": _read_bytes(config.client_key_path), - "certificate_chain": _read_bytes(config.client_cert_path), - } +def _metrics_endpoint(endpoint: str) -> str: + """The OTLP/HTTP metric exporter wants the full URL including the signal path.""" + trimmed = endpoint.rstrip("/") + return trimmed if trimmed.endswith(_METRICS_PATH) else f"{trimmed}{_METRICS_PATH}" def _resource_attributes(config: BillingMetricsConfig) -> Dict[str, AttributeValue]: @@ -87,8 +82,20 @@ def _billable_attributes( def build_mtls_meter_provider(config: BillingMetricsConfig) -> MeterProvider: - credentials: grpc.ChannelCredentials = grpc.ssl_channel_credentials(**_mtls_credentials_args(config)) - exporter = OTLPMetricExporter(endpoint=config.endpoint, credentials=credentials) + """OTLP/HTTP exporter presenting a TLS client certificate. + + The collector's load balancer terminates mutual TLS and validates the client + certificate against our CA. Server verification uses the system trust store + (the collector presents a public web-PKI certificate); ca_cert_path overrides + it only for private/test collectors. + """ + exporter = OTLPMetricExporter( + endpoint=_metrics_endpoint(config.endpoint), + # None -> exporter falls back to the system trust store. + certificate_file=config.ca_cert_path, + client_certificate_file=config.client_cert_path, + client_key_file=config.client_key_path, + ) reader = PeriodicExportingMetricReader(exporter, export_interval_millis=config.export_interval_ms) return MeterProvider(metric_readers=[reader], resource=Resource.create(_resource_attributes(config))) @@ -127,6 +134,8 @@ def load_billing_metrics_config( endpoint = os.getenv(ENDPOINT_ENV) client_cert = os.getenv(CLIENT_CERT_ENV) client_key = os.getenv(CLIENT_KEY_ENV) + # Optional: only for private/test collectors whose server cert is not on the + # public web PKI. The production collector needs no CA override. ca_cert = os.getenv(CA_CERT_ENV) missing = [ @@ -135,18 +144,18 @@ def load_billing_metrics_config( (ENDPOINT_ENV, endpoint), (CLIENT_CERT_ENV, client_cert), (CLIENT_KEY_ENV, client_key), - (CA_CERT_ENV, ca_cert), ) if not value ] - if endpoint is None or client_cert is None or client_key is None or ca_cert is None: + if endpoint is None or client_cert is None or client_key is None: verbose_proxy_logger.warning( "Enterprise billing metrics disabled: licensed deployment missing config (%s)", ", ".join(missing), ) return None - unreadable = [path for path in (client_cert, client_key, ca_cert) if not os.path.isfile(path)] + required_paths = [client_cert, client_key] + ([ca_cert] if ca_cert else []) + unreadable = [path for path in required_paths if not os.path.isfile(path)] if unreadable: verbose_proxy_logger.warning( "Enterprise billing metrics disabled: certificate file(s) not found: %s", diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py index 82bbe7611bf4..7a0bd8e2d1af 100644 --- a/litellm/proxy/middleware/billable_request_metrics_middleware.py +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -9,7 +9,7 @@ """ from enum import Enum -from typing import Optional, Protocol, Sequence, Tuple, runtime_checkable +from typing import Callable, Optional, Protocol, Sequence, Tuple, runtime_checkable from starlette.types import ASGIApp, Message, Receive, Scope, Send @@ -28,7 +28,12 @@ def record(self, *, category: BillableCategory, route: str, status_code: int, mo _MODEL_ID_HEADER = b"x-litellm-model-id" # Ordered: a longer suffix that shares an ending with a shorter one must come -# first, e.g. "/chat/completions" before "/completions". +# first, e.g. "/chat/completions" before "/completions". This is the inference +# surface that writes a SpendLogs row on success -- the same population the +# admin UI usage page counts -- so the collector and the UI report the same +# number. LLM routes are POST-only inference calls; GET reads on the same +# resources (list/status/content) are not billable and are excluded by the +# method gate in classify_billable_request. _LLM_ROUTE_SUFFIXES: Tuple[str, ...] = ( "/chat/completions", "/completions", @@ -37,9 +42,17 @@ def record(self, *, category: BillableCategory, route: str, status_code: int, mo "/rerank", "/moderations", "/images/generations", + "/images/edits", + "/images/variations", "/audio/transcriptions", "/audio/translations", "/audio/speech", + "/messages", # Anthropic /v1/messages (count_tokens does not end with /messages) + "/videos", # create; GET list is excluded by the POST gate + "/remix", # /v1/videos/{id}/remix + "/ocr", + ":generateContent", # Gemini-native /v1beta/models/{model}:generateContent + ":streamGenerateContent", ) @@ -47,7 +60,7 @@ def _classify_llm_route(path: str) -> Optional[str]: return next((suffix for suffix in _LLM_ROUTE_SUFFIXES if path == suffix or path.endswith(suffix)), None) -def classify_billable_request(path: str) -> Optional[Tuple[BillableCategory, str]]: +def classify_billable_request(path: str, method: str = "POST") -> Optional[Tuple[BillableCategory, str]]: """Map a request path to its (category, normalized route), or None if not billable.""" normalized = path.rstrip("/") or "/" @@ -60,6 +73,11 @@ def classify_billable_request(path: str) -> Optional[Tuple[BillableCategory, str if normalized == "/a2a" or normalized.startswith("/a2a/"): return (BillableCategory.A2A, "/a2a") + # Inference calls are POSTs; GETs on these paths are reads (list videos, + # fetch a response object), which write no SpendLogs row and must not bill. + if method.upper() != "POST": + return None + llm_route = _classify_llm_route(normalized) if llm_route is not None: return (BillableCategory.LLM, llm_route) @@ -81,17 +99,40 @@ class BillableRequestMetricsMiddleware: `http.response.start` message, and never blocks or fails the request path. """ - def __init__(self, app: ASGIApp, recorder: Optional[BillingRecorder] = None) -> None: + def __init__( + self, + app: ASGIApp, + recorder: Optional[BillingRecorder] = None, + recorder_factory: Optional[Callable[[], Optional[BillingRecorder]]] = None, + ) -> None: self.app = app self.recorder = recorder + # The factory defers recorder construction to the first request, AFTER the + # startup event has loaded the YAML config's environment_variables (license + # and cert env vars). Building at import time captured recorder=None for + # deployments configured that way. Resolved exactly once; the result + # (including None) is cached. + self._recorder_factory = recorder_factory + self._resolved = recorder_factory is None + + def _resolve_recorder(self) -> Optional[BillingRecorder]: + if not self._resolved: + factory = self._recorder_factory + self.recorder = factory() if factory is not None else self.recorder + self._resolved = True + return self.recorder async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - recorder = self.recorder - if recorder is None or scope["type"] != "http": + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + recorder = self._resolve_recorder() + if recorder is None: await self.app(scope, receive, send) return - classification = classify_billable_request(scope.get("path", "")) + classification = classify_billable_request(scope.get("path", ""), scope.get("method", "POST")) if classification is None: await self.app(scope, receive, send) return diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c27f419490f8..8d1f85287b82 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1813,9 +1813,17 @@ def _restructure_ui_html_files(ui_root: str) -> None: app.add_middleware(SecurityHeadersMiddleware) app.add_middleware( BillableRequestMetricsMiddleware, - recorder=build_billing_metrics_recorder( + # Factory, not an instance: the recorder is resolved on the first request so + # it sees premium_user and the billing env vars AFTER proxy_startup_event has + # loaded the YAML config's environment_variables. Building it here at import + # time would permanently capture recorder=None for YAML-configured + # deployments. The lambda reads the module globals at call time. + recorder_factory=lambda: build_billing_metrics_recorder( premium=premium_user, - license_data=premium_user_data, + # Read from the license check, not the premium_user_data module global: + # that global is bound once at import and goes stale when the license + # arrives via the YAML config's environment_variables. + license_data=_license_check.airgapped_license_data, litellm_version=version, ), ) diff --git a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py index 2ccd0921bf56..ae545d1b7598 100644 --- a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py +++ b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py @@ -2,9 +2,10 @@ Tests for the enterprise billing-metrics recorder and its factory. These verify the license gate, the missing-config and missing-cert disable -paths, the cert-file -> mTLS credential mapping (CA verifies the server, -cert+key authenticate us), the metric attribute mapping, and that recording -produces the expected OTLP counter via an in-memory reader. +paths, the OTLP/HTTP exporter wiring (client cert+key authenticate us to the +collector's mTLS-terminating front end; CA override optional for private +collectors), the metric attribute mapping, and that recording produces the +expected OTLP counter via an in-memory reader. """ from pathlib import Path @@ -113,17 +114,55 @@ def test_export_interval_default_and_override(monkeypatch): assert bm._export_interval_ms() == bm.DEFAULT_EXPORT_INTERVAL_MS -# ── mTLS credential mapping ─────────────────────────────────────────────────── +# ── OTLP/HTTP exporter wiring ───────────────────────────────────────────────── -def test_mtls_credentials_args_map_files_correctly(tmp_path): - """CA -> root_certificates (verify server); client cert/key -> certificate_chain/private_key (authenticate us).""" - args = bm._mtls_credentials_args(_config(tmp_path)) - assert args == { - "root_certificates": b"ca-bytes", - "private_key": b"client-key-bytes", - "certificate_chain": b"client-cert-bytes", - } +def test_metrics_endpoint_appends_signal_path(): + assert bm._metrics_endpoint("https://telemetry.example.com") == "https://telemetry.example.com/v1/metrics" + assert bm._metrics_endpoint("https://telemetry.example.com/") == "https://telemetry.example.com/v1/metrics" + assert bm._metrics_endpoint("https://telemetry.example.com/v1/metrics") == "https://telemetry.example.com/v1/metrics" + + +def test_meter_provider_wires_client_cert_into_http_exporter(tmp_path, monkeypatch): + """Client cert+key authenticate us at the collector's mTLS front end; CA override rides certificate_file.""" + captured = {} + + class _FakeExporter: + # PeriodicExportingMetricReader probes these on the exporter it wraps. + _preferred_temporality: dict = {} + _preferred_aggregation: dict = {} + + def __init__(self, **kwargs): + captured.update(kwargs) + + def export(self, *args, **kwargs): + return None + + def shutdown(self, *args, **kwargs): + return None + + def force_flush(self, *args, **kwargs): + return True + + monkeypatch.setattr(bm, "OTLPMetricExporter", _FakeExporter) + config = _config(tmp_path) + provider = bm.build_mtls_meter_provider(config) + provider.shutdown() + + assert captured["endpoint"] == "https://collector.example:4317/v1/metrics" + assert captured["client_certificate_file"] == config.client_cert_path + assert captured["client_key_file"] == config.client_key_path + assert captured["certificate_file"] == config.ca_cert_path + + +def test_load_config_without_ca_is_valid(monkeypatch, tmp_path): + """The production collector presents a public web-PKI cert: no CA override required.""" + paths = _write_certs(tmp_path) + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://telemetry.example.com") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, paths[bm.CLIENT_CERT_ENV]) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, paths[bm.CLIENT_KEY_ENV]) + config = bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") + assert config is not None and config.ca_cert_path is None # ── Resource and metric attributes ──────────────────────────────────────────── diff --git a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py index 6525caf3d642..82d9d7a77ebd 100644 --- a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py +++ b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py @@ -80,6 +80,18 @@ def test_is_pure_asgi_not_base_http_middleware(): ("/v1/responses", (BillableCategory.LLM, "/responses")), ("/v1/rerank", (BillableCategory.LLM, "/rerank")), ("/v1/audio/transcriptions", (BillableCategory.LLM, "/audio/transcriptions")), + # Routes from the metering-bypass finding: authenticated inference + # endpoints that must bill and previously classified as None. + ("/v1/images/edits", (BillableCategory.LLM, "/images/edits")), + ("/images/edits", (BillableCategory.LLM, "/images/edits")), + ("/openai/deployments/dall-e/images/edits", (BillableCategory.LLM, "/images/edits")), + ("/v1/images/variations", (BillableCategory.LLM, "/images/variations")), + ("/v1/messages", (BillableCategory.LLM, "/messages")), + ("/v1/videos", (BillableCategory.LLM, "/videos")), + ("/v1/videos/video_123/remix", (BillableCategory.LLM, "/remix")), + ("/v1/ocr", (BillableCategory.LLM, "/ocr")), + ("/v1beta/models/gemini-2.5-pro:generateContent", (BillableCategory.LLM, ":generateContent")), + ("/v1beta/models/gemini-2.5-pro:streamGenerateContent", (BillableCategory.LLM, ":streamGenerateContent")), ("/mcp", (BillableCategory.MCP, "/mcp")), ("/mcp/", (BillableCategory.MCP, "/mcp")), ("/mcp/tools/list", (BillableCategory.MCP, "/mcp")), @@ -97,12 +109,37 @@ def test_classify_billable(path: str, expected: Tuple[BillableCategory, str]): @pytest.mark.parametrize( "path", - ["/health", "/health/readiness", "/metrics", "/ui", "/", "/v1/models", "/key/generate", "/v1/files"], + [ + "/health", + "/health/readiness", + "/metrics", + "/ui", + "/", + "/v1/models", + "/key/generate", + "/v1/files", + # tokenization helper, not an inference call + "/v1/messages/count_tokens", + ], ) def test_classify_non_billable_returns_none(path: str): assert classify_billable_request(path) is None +@pytest.mark.parametrize( + "path", + ["/v1/videos", "/v1/responses", "/v1/chat/completions", "/v1/messages"], +) +def test_classify_get_reads_are_not_billable(path: str): + """GETs on inference resources (list videos, fetch a response) write no + SpendLogs row and must not bill; only POST inference calls count.""" + assert classify_billable_request(path, "GET") is None + + +def test_classify_mcp_not_method_gated(): + assert classify_billable_request("/mcp/tools/list", "GET") == (BillableCategory.MCP, "/mcp") + + def test_chat_completions_not_misclassified_as_plain_completions(): """The /chat/completions suffix must win over /completions so the route label is correct.""" category, route = classify_billable_request("/v1/chat/completions") @@ -188,3 +225,61 @@ async def __call__(self, scope, receive, send): mw = BillableRequestMetricsMiddleware(_Inner(), recorder=recorder) asyncio.run(mw({"type": "lifespan"}, None, None)) # type: ignore[arg-type] assert recorder.calls == [] + + +# ── lazy recorder factory ───────────────────────────────────────────────────── + + +def test_recorder_factory_not_called_at_init(): + """The factory must run on the first request, not at middleware construction: + building at import time captured recorder=None before the YAML config's + environment_variables loaded the license and cert env vars.""" + calls = [] + + def factory(): + calls.append(1) + return FakeRecorder() + + class _Inner: + async def __call__(self, scope, receive, send): + return None + + BillableRequestMetricsMiddleware(_Inner(), recorder_factory=factory) + assert calls == [] + + +def test_recorder_factory_resolved_once_on_first_request(): + recorder = FakeRecorder() + calls = [] + + def factory(): + calls.append(1) + return recorder + + client = TestClient(_make_app_with_factory(factory, status_code=200)) + client.post("/v1/chat/completions") + client.post("/v1/chat/completions") + assert calls == [1] + assert len(recorder.calls) == 2 + + +def test_recorder_factory_returning_none_is_cached(): + calls = [] + + def factory(): + calls.append(1) + return None + + client = TestClient(_make_app_with_factory(factory, status_code=200)) + assert client.post("/v1/chat/completions").status_code == 200 + assert client.post("/v1/chat/completions").status_code == 200 + assert calls == [1] + + +def _make_app_with_factory(factory, status_code: int) -> Starlette: + async def handler(request: Request) -> Response: + return JSONResponse({}, status_code=status_code) + + app = Starlette(routes=[Route("/v1/chat/completions", handler, methods=["POST"])]) + app.add_middleware(BillableRequestMetricsMiddleware, recorder_factory=factory) + return app From 87adc8f3a426315b9e72a615284d91ef6f869fca Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Tue, 7 Jul 2026 16:21:14 +0300 Subject: [PATCH 03/29] 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 --- litellm/proxy/proxy_server.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 8d1f85287b82..9125e0923664 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -436,12 +436,19 @@ def generate_feedback_box(): router as plugin_router, register_plugins_from_config, ) -from litellm.proxy.enterprise_billing.billing_metrics import ( - build_billing_metrics_recorder, -) from litellm.proxy.middleware.billable_request_metrics_middleware import ( BillableRequestMetricsMiddleware, + BillingRecorder, ) + +try: + from litellm.proxy.enterprise_billing.billing_metrics import ( + build_billing_metrics_recorder as _build_billing_metrics_recorder, + ) + + build_billing_metrics_recorder: Optional[Callable[..., Optional[BillingRecorder]]] = _build_billing_metrics_recorder +except ImportError: + build_billing_metrics_recorder = None from litellm.proxy.middleware.in_flight_requests_middleware import ( InFlightRequestsMiddleware, ) @@ -1818,13 +1825,17 @@ def _restructure_ui_html_files(ui_root: str) -> None: # loaded the YAML config's environment_variables. Building it here at import # time would permanently capture recorder=None for YAML-configured # deployments. The lambda reads the module globals at call time. - recorder_factory=lambda: build_billing_metrics_recorder( - premium=premium_user, - # Read from the license check, not the premium_user_data module global: - # that global is bound once at import and goes stale when the license - # arrives via the YAML config's environment_variables. - license_data=_license_check.airgapped_license_data, - litellm_version=version, + recorder_factory=lambda: ( + build_billing_metrics_recorder( + premium=premium_user, + # Read from the license check, not the premium_user_data module + # global: that global is bound once at import and goes stale when + # the license arrives via the YAML config's environment_variables. + license_data=_license_check.airgapped_license_data, + litellm_version=version, + ) + if build_billing_metrics_recorder is not None + else None ), ) From dd263fd9d011719eb15bf58e898a12caabc50a71 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Tue, 7 Jul 2026 16:24:32 +0300 Subject: [PATCH 04/29] 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 --- .../enterprise_billing/billing_metrics.py | 20 ++++++++-------- .../billable_request_metrics_middleware.py | 8 +++---- litellm/proxy/proxy_server.py | 23 ++++++++----------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/litellm/proxy/enterprise_billing/billing_metrics.py b/litellm/proxy/enterprise_billing/billing_metrics.py index 8ef6d7027a98..1b019901676d 100644 --- a/litellm/proxy/enterprise_billing/billing_metrics.py +++ b/litellm/proxy/enterprise_billing/billing_metrics.py @@ -15,7 +15,7 @@ import os from dataclasses import dataclass -from typing import TYPE_CHECKING, Dict, Optional, Union +from typing import TYPE_CHECKING, Optional, Union from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter from opentelemetry.metrics import Counter @@ -24,7 +24,9 @@ from opentelemetry.sdk.resources import Resource from litellm._logging import verbose_proxy_logger -from litellm.proxy.middleware.billable_request_metrics_middleware import BillableCategory +from litellm.proxy.middleware.billable_request_metrics_middleware import ( + BillableCategory, +) if TYPE_CHECKING: from litellm.proxy._types import EnterpriseLicenseData @@ -60,24 +62,24 @@ def _metrics_endpoint(endpoint: str) -> str: return trimmed if trimmed.endswith(_METRICS_PATH) else f"{trimmed}{_METRICS_PATH}" -def _resource_attributes(config: BillingMetricsConfig) -> Dict[str, AttributeValue]: - base: Dict[str, AttributeValue] = { +def _resource_attributes(config: BillingMetricsConfig) -> dict[str, AttributeValue]: + base: dict[str, AttributeValue] = { "service.name": "litellm-proxy", "litellm.version": config.litellm_version, } - license_attr: Dict[str, AttributeValue] = {"litellm.license.id": config.license_id} if config.license_id else {} + license_attr: dict[str, AttributeValue] = {"litellm.license.id": config.license_id} if config.license_id else {} return {**base, **license_attr} def _billable_attributes( category: BillableCategory, route: str, status_code: int, model_id: Optional[str] -) -> Dict[str, AttributeValue]: - base: Dict[str, AttributeValue] = { +) -> dict[str, AttributeValue]: + base: dict[str, AttributeValue] = { "litellm.endpoint.category": category.value, "http.route": route, "http.response.status_code": status_code, } - model_attr: Dict[str, AttributeValue] = {"litellm.model_id": model_id} if model_id else {} + model_attr: dict[str, AttributeValue] = {"litellm.model_id": model_id} if model_id else {} return {**base, **model_attr} @@ -187,6 +189,6 @@ def build_billing_metrics_recorder( try: return BillingMetricsRecorder(build_mtls_meter_provider(config)) - except Exception as exc: + except Exception as exc: # noqa: BLE001 -- metering must never break proxy startup verbose_proxy_logger.warning("Enterprise billing metrics disabled: failed to initialize exporter: %s", exc) return None diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py index 7a0bd8e2d1af..5410520094b6 100644 --- a/litellm/proxy/middleware/billable_request_metrics_middleware.py +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -9,7 +9,7 @@ """ from enum import Enum -from typing import Callable, Optional, Protocol, Sequence, Tuple, runtime_checkable +from typing import Callable, Optional, Protocol, Sequence, runtime_checkable from starlette.types import ASGIApp, Message, Receive, Scope, Send @@ -34,7 +34,7 @@ def record(self, *, category: BillableCategory, route: str, status_code: int, mo # number. LLM routes are POST-only inference calls; GET reads on the same # resources (list/status/content) are not billable and are excluded by the # method gate in classify_billable_request. -_LLM_ROUTE_SUFFIXES: Tuple[str, ...] = ( +_LLM_ROUTE_SUFFIXES: tuple[str, ...] = ( "/chat/completions", "/completions", "/embeddings", @@ -60,7 +60,7 @@ def _classify_llm_route(path: str) -> Optional[str]: return next((suffix for suffix in _LLM_ROUTE_SUFFIXES if path == suffix or path.endswith(suffix)), None) -def classify_billable_request(path: str, method: str = "POST") -> Optional[Tuple[BillableCategory, str]]: +def classify_billable_request(path: str, method: str = "POST") -> Optional[tuple[BillableCategory, str]]: """Map a request path to its (category, normalized route), or None if not billable.""" normalized = path.rstrip("/") or "/" @@ -84,7 +84,7 @@ def classify_billable_request(path: str, method: str = "POST") -> Optional[Tuple return None -def _extract_model_id(headers: Sequence[Tuple[bytes, bytes]]) -> Optional[str]: +def _extract_model_id(headers: Sequence[tuple[bytes, bytes]]) -> Optional[str]: return next( (value.decode("latin-1") for name, value in headers if name.lower() == _MODEL_ID_HEADER and value), None, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9125e0923664..e28ea005fcc6 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -432,14 +432,16 @@ def generate_feedback_box(): create_object_audit_log, ) from litellm.proxy.memory.memory_endpoints import router as memory_router -from litellm.proxy.plugin_routes import ( - router as plugin_router, - register_plugins_from_config, -) from litellm.proxy.middleware.billable_request_metrics_middleware import ( BillableRequestMetricsMiddleware, BillingRecorder, ) +from litellm.proxy.plugin_routes import ( + register_plugins_from_config, +) +from litellm.proxy.plugin_routes import ( + router as plugin_router, +) try: from litellm.proxy.enterprise_billing.billing_metrics import ( @@ -468,13 +470,11 @@ def generate_feedback_box(): ) from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( passthrough_endpoint_router, + vertex_ai_live_websocket_passthrough, ) from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( router as llm_passthrough_router, ) -from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( - vertex_ai_live_websocket_passthrough, -) from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( initialize_pass_through_endpoints, ) @@ -559,21 +559,18 @@ def generate_feedback_box(): from litellm.types.realtime import RealtimeQueryParams from litellm.types.router import ( DeploymentTypedDict, -) -from litellm.types.router import ModelInfo as RouterModelInfo -from litellm.types.router import ( RouterGeneralSettings, SearchToolTypedDict, updateDeployment, ) +from litellm.types.router import ModelInfo as RouterModelInfo from litellm.types.scheduler import DefaultPriorities from litellm.types.secret_managers.main import ( KeyManagementSettings, KeyManagementSystem, ) -from litellm.types.utils import CredentialItem, CustomHuggingfaceTokenizer +from litellm.types.utils import CredentialItem, CustomHuggingfaceTokenizer, RawRequestTypedDict, StandardLoggingPayload from litellm.types.utils import ModelInfo as ModelMapInfo -from litellm.types.utils import RawRequestTypedDict, StandardLoggingPayload from litellm.utils import _add_custom_logger_callback_to_specific_event try: @@ -969,11 +966,11 @@ async def _run_pw_migration(): if is_otel_v2_enabled(): from opentelemetry import trace as _otel_trace - from litellm.litellm_core_utils.litellm_logging import _in_memory_loggers from litellm.integrations.otel.logger import ( OpenTelemetryV2, publish_global_otel_v2_provider, ) + from litellm.litellm_core_utils.litellm_logging import _in_memory_loggers registered = open_telemetry_logger if isinstance(open_telemetry_logger, OpenTelemetryV2) else None publish_global_otel_v2_provider( From 6484270af280aff88d13c6a38dabde511343fdb7 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Tue, 7 Jul 2026 16:32:12 +0300 Subject: [PATCH 05/29] 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 --- .../billable_request_metrics_middleware.py | 23 ++++++++++++++++++- ...est_billable_request_metrics_middleware.py | 15 ++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py index 5410520094b6..be38761dc1ca 100644 --- a/litellm/proxy/middleware/billable_request_metrics_middleware.py +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -13,6 +13,8 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send +from litellm.proxy._types import LiteLLMRoutes + class BillableCategory(str, Enum): LLM = "llm" @@ -51,13 +53,32 @@ def record(self, *, category: BillableCategory, route: str, status_code: int, mo "/videos", # create; GET list is excluded by the POST gate "/remix", # /v1/videos/{id}/remix "/ocr", + "/search", # /v1/search and /v1/vector_stores/{id}/search + "/rag/query", + "/rag/ingest", ":generateContent", # Gemini-native /v1beta/models/{model}:generateContent ":streamGenerateContent", ) +# Provider passthrough prefixes (e.g. /bedrock/..., /vertex-ai/...) carry real +# inference calls that write SpendLogs rows, so they bill. Anchored to the +# routes enum so new providers are picked up without touching this module. +# /langfuse forwards observability traffic, not inference: it writes no +# SpendLogs row and must not bill. +_NON_BILLABLE_PASSTHROUGH_PREFIXES = frozenset({"/langfuse"}) +_PASSTHROUGH_PREFIXES: tuple[str, ...] = tuple( + prefix + for prefix in LiteLLMRoutes.mapped_pass_through_routes.value + if prefix not in _NON_BILLABLE_PASSTHROUGH_PREFIXES +) + def _classify_llm_route(path: str) -> Optional[str]: - return next((suffix for suffix in _LLM_ROUTE_SUFFIXES if path == suffix or path.endswith(suffix)), None) + suffix_match = next((suffix for suffix in _LLM_ROUTE_SUFFIXES if path == suffix or path.endswith(suffix)), None) + if suffix_match is not None: + return suffix_match + # Deep passthrough paths only: the bare prefix itself is not an inference call. + return next((prefix for prefix in _PASSTHROUGH_PREFIXES if path.startswith(f"{prefix}/")), None) def classify_billable_request(path: str, method: str = "POST") -> Optional[tuple[BillableCategory, str]]: diff --git a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py index 82d9d7a77ebd..a044cf65510e 100644 --- a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py +++ b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py @@ -92,6 +92,17 @@ def test_is_pure_asgi_not_base_http_middleware(): ("/v1/ocr", (BillableCategory.LLM, "/ocr")), ("/v1beta/models/gemini-2.5-pro:generateContent", (BillableCategory.LLM, ":generateContent")), ("/v1beta/models/gemini-2.5-pro:streamGenerateContent", (BillableCategory.LLM, ":streamGenerateContent")), + # SpendLogs-producing routes surfaced by the route-inventory audit + ("/v1/search", (BillableCategory.LLM, "/search")), + ("/v1/vector_stores/vs_1/search", (BillableCategory.LLM, "/search")), + ("/v1/rag/query", (BillableCategory.LLM, "/rag/query")), + ("/rag/ingest", (BillableCategory.LLM, "/rag/ingest")), + # Provider passthrough carries real inference and writes SpendLogs + ("/bedrock/model/anthropic.claude-v2/invoke", (BillableCategory.LLM, "/bedrock")), + ("/vertex-ai/publishers/google/models/gemini:predict", (BillableCategory.LLM, "/vertex-ai")), + ("/cohere/v2/chat", (BillableCategory.LLM, "/cohere")), + # Passthrough path ending in a known suffix keeps the finer-grained label + ("/anthropic/v1/messages", (BillableCategory.LLM, "/messages")), ("/mcp", (BillableCategory.MCP, "/mcp")), ("/mcp/", (BillableCategory.MCP, "/mcp")), ("/mcp/tools/list", (BillableCategory.MCP, "/mcp")), @@ -120,6 +131,10 @@ def test_classify_billable(path: str, expected: Tuple[BillableCategory, str]): "/v1/files", # tokenization helper, not an inference call "/v1/messages/count_tokens", + # observability passthrough writes no SpendLogs row + "/langfuse/api/public/ingestion", + # a bare provider prefix is not an inference call + "/bedrock", ], ) def test_classify_non_billable_returns_none(path: str): From 3f76acacc1c59ce3c66ead43686619dc1d6e71e8 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 8 Jul 2026 11:11:51 +0300 Subject: [PATCH 06/29] 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. --- .../billable_request_metrics_middleware.py | 39 +++++++++--- ...est_billable_request_metrics_middleware.py | 60 ++++++++++++++++--- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py index be38761dc1ca..6f6cf8e61062 100644 --- a/litellm/proxy/middleware/billable_request_metrics_middleware.py +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -8,6 +8,7 @@ middleware is a transparent pass-through. """ +import re from enum import Enum from typing import Callable, Optional, Protocol, Sequence, runtime_checkable @@ -81,18 +82,40 @@ def _classify_llm_route(path: str) -> Optional[str]: return next((prefix for prefix in _PASSTHROUGH_PREFIXES if path.startswith(f"{prefix}/")), None) +_MCP_MANAGEMENT_PREFIX = "/v1/mcp" +_MCP_DYNAMIC_TRANSPORT = re.compile(r"/(?:toolset/)?[^/]+/mcp") + +_A2A_INVOKE_SUFFIX = "/message/send" +_A2A_TRANSPORT_PREFIXES: tuple[str, ...] = ("/v1/a2a/", "/a2a/") + + +def _classify_mcp_route(path: str) -> Optional[str]: + if path == _MCP_MANAGEMENT_PREFIX or path.startswith(f"{_MCP_MANAGEMENT_PREFIX}/"): + return None + if path == "/mcp" or path.startswith("/mcp/"): + return "/mcp" + if _MCP_DYNAMIC_TRANSPORT.fullmatch(path) is not None: + return "/mcp" + return None + + +def _classify_a2a_route(path: str) -> Optional[str]: + if path.endswith(_A2A_INVOKE_SUFFIX) and any(path.startswith(prefix) for prefix in _A2A_TRANSPORT_PREFIXES): + return "/a2a" + return None + + def classify_billable_request(path: str, method: str = "POST") -> Optional[tuple[BillableCategory, str]]: """Map a request path to its (category, normalized route), or None if not billable.""" normalized = path.rstrip("/") or "/" - if normalized == "/mcp" or normalized.startswith("/mcp/"): - return (BillableCategory.MCP, "/mcp") - if normalized == "/v1/mcp" or normalized.startswith("/v1/mcp/"): - return (BillableCategory.MCP, "/v1/mcp") - if normalized == "/v1/a2a" or normalized.startswith("/v1/a2a/"): - return (BillableCategory.A2A, "/v1/a2a") - if normalized == "/a2a" or normalized.startswith("/a2a/"): - return (BillableCategory.A2A, "/a2a") + mcp_route = _classify_mcp_route(normalized) + if mcp_route is not None: + return (BillableCategory.MCP, mcp_route) + + a2a_route = _classify_a2a_route(normalized) + if a2a_route is not None: + return (BillableCategory.A2A, a2a_route) # Inference calls are POSTs; GETs on these paths are reads (list videos, # fetch a response object), which write no SpendLogs row and must not bill. diff --git a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py index a044cf65510e..2355fd406a25 100644 --- a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py +++ b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py @@ -46,7 +46,10 @@ async def handler(request: Request) -> Response: "/v1/embeddings", "/v1/completions", "/mcp", + "/github/mcp", + "/toolset/my-tools/mcp", "/v1/mcp/tools", + "/v1/mcp/server", "/a2a/agent-1/message/send", "/v1/a2a/discover", "/health", @@ -106,12 +109,12 @@ def test_is_pure_asgi_not_base_http_middleware(): ("/mcp", (BillableCategory.MCP, "/mcp")), ("/mcp/", (BillableCategory.MCP, "/mcp")), ("/mcp/tools/list", (BillableCategory.MCP, "/mcp")), - ("/v1/mcp", (BillableCategory.MCP, "/v1/mcp")), - ("/v1/mcp/servers", (BillableCategory.MCP, "/v1/mcp")), - ("/a2a", (BillableCategory.A2A, "/a2a")), + ("/github/mcp", (BillableCategory.MCP, "/mcp")), + ("/github/mcp/", (BillableCategory.MCP, "/mcp")), + ("/toolset/my-tools/mcp", (BillableCategory.MCP, "/mcp")), + ("/github,slack/mcp", (BillableCategory.MCP, "/mcp")), ("/a2a/agent-1/message/send", (BillableCategory.A2A, "/a2a")), - ("/v1/a2a", (BillableCategory.A2A, "/v1/a2a")), - ("/v1/a2a/discover", (BillableCategory.A2A, "/v1/a2a")), + ("/v1/a2a/agent-9/message/send", (BillableCategory.A2A, "/a2a")), ], ) def test_classify_billable(path: str, expected: Tuple[BillableCategory, str]): @@ -135,12 +138,37 @@ def test_classify_billable(path: str, expected: Tuple[BillableCategory, str]): "/langfuse/api/public/ingestion", # a bare provider prefix is not an inference call "/bedrock", + "/v1/mcp", + "/v1/mcp/tools", + "/v1/mcp/server", + "/v1/mcp/server/health", + "/v1/mcp/server/some-id", + "/v1/mcp/server/register", + "/v1/mcp/oauth/some-id/authorize", + "/a2a/agent-1/.well-known/agent-card.json", + "/v1/a2a/discover", + "/.well-known/oauth-protected-resource/github/mcp", + "/mcp-rest/tools/list", + "/mcp-rest/tools/call", ], ) def test_classify_non_billable_returns_none(path: str): assert classify_billable_request(path) is None +@pytest.mark.parametrize( + "path", + [ + "/v1/mcp/tools", + "/v1/mcp/server", + "/v1/mcp/server/register", + "/v1/a2a/discover", + ], +) +def test_classify_management_writes_are_not_billable(path: str): + assert classify_billable_request(path, "POST") is None + + @pytest.mark.parametrize( "path", ["/v1/videos", "/v1/responses", "/v1/chat/completions", "/v1/messages"], @@ -190,7 +218,7 @@ def test_records_once_on_2xx_llm_with_model_id(): def test_records_mcp_category(): recorder = FakeRecorder() - TestClient(_make_app(recorder)).post("/v1/mcp/tools") + TestClient(_make_app(recorder)).post("/github/mcp") assert len(recorder.calls) == 1 and recorder.calls[0]["category"] == BillableCategory.MCP @@ -200,6 +228,24 @@ def test_records_a2a_category(): assert len(recorder.calls) == 1 and recorder.calls[0]["category"] == BillableCategory.A2A +def test_does_not_record_mcp_management_read(): + recorder = FakeRecorder() + TestClient(_make_app(recorder)).get("/v1/mcp/tools") + assert recorder.calls == [] + + +def test_does_not_record_mcp_management_write(): + recorder = FakeRecorder() + TestClient(_make_app(recorder)).post("/v1/mcp/server") + assert recorder.calls == [] + + +def test_does_not_record_a2a_discovery(): + recorder = FakeRecorder() + TestClient(_make_app(recorder)).post("/v1/a2a/discover") + assert recorder.calls == [] + + def test_does_not_record_on_4xx(): recorder = FakeRecorder() TestClient(_make_app(recorder, status_code=404)).post("/v1/chat/completions") @@ -220,7 +266,7 @@ def test_does_not_record_non_billable_path(): def test_no_model_id_when_header_absent(): recorder = FakeRecorder() - TestClient(_make_app(recorder, status_code=200, model_id=None)).post("/v1/mcp/tools") + TestClient(_make_app(recorder, status_code=200, model_id=None)).post("/github/mcp") assert recorder.calls[0]["model_id"] is None From 6f12dda7ce5cf097f4c134a8f042792642acc37f Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 10:29:25 +0300 Subject: [PATCH 07/29] 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 --- .../enterprise_billing/billing_metrics.py | 2 +- .../billable_request_metrics_middleware.py | 34 +++++++++-- .../test_billing_metrics.py | 11 ++++ ...est_billable_request_metrics_middleware.py | 57 ++++++++++++++++++- 4 files changed, 94 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/enterprise_billing/billing_metrics.py b/litellm/proxy/enterprise_billing/billing_metrics.py index 1b019901676d..ea8c79509f6d 100644 --- a/litellm/proxy/enterprise_billing/billing_metrics.py +++ b/litellm/proxy/enterprise_billing/billing_metrics.py @@ -149,7 +149,7 @@ def load_billing_metrics_config( ) if not value ] - if endpoint is None or client_cert is None or client_key is None: + if not endpoint or not client_cert or not client_key: verbose_proxy_logger.warning( "Enterprise billing metrics disabled: licensed deployment missing config (%s)", ", ".join(missing), diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py index 6f6cf8e61062..6a3385702379 100644 --- a/litellm/proxy/middleware/billable_request_metrics_middleware.py +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -9,11 +9,13 @@ """ import re +import threading from enum import Enum from typing import Callable, Optional, Protocol, Sequence, runtime_checkable from starlette.types import ASGIApp, Message, Receive, Scope, Send +from litellm._logging import verbose_proxy_logger from litellm.proxy._types import LiteLLMRoutes @@ -50,7 +52,6 @@ def record(self, *, category: BillableCategory, route: str, status_code: int, mo "/audio/transcriptions", "/audio/translations", "/audio/speech", - "/messages", # Anthropic /v1/messages (count_tokens does not end with /messages) "/videos", # create; GET list is excluded by the POST gate "/remix", # /v1/videos/{id}/remix "/ocr", @@ -61,6 +62,15 @@ def record(self, *, category: BillableCategory, route: str, status_code: int, mo ":streamGenerateContent", ) +# Exact paths only: a suffix match would also catch non-inference resources that +# share the ending, e.g. the OpenAI Assistants route /v1/threads/{id}/messages +# writes no SpendLogs row and must not bill, unlike Anthropic /v1/messages. +_LLM_ROUTE_EXACT: tuple[str, ...] = ( + "/v1/messages", + "/interactions", # Google Interactions create; /{id} reads and /cancel do not match + "/v1beta/interactions", +) + # Provider passthrough prefixes (e.g. /bedrock/..., /vertex-ai/...) carry real # inference calls that write SpendLogs rows, so they bill. Anchored to the # routes enum so new providers are picked up without touching this module. @@ -75,6 +85,9 @@ def record(self, *, category: BillableCategory, route: str, status_code: int, mo def _classify_llm_route(path: str) -> Optional[str]: + exact_match = next((route for route in _LLM_ROUTE_EXACT if path == route), None) + if exact_match is not None: + return exact_match suffix_match = next((suffix for suffix in _LLM_ROUTE_SUFFIXES if path == suffix or path.endswith(suffix)), None) if suffix_match is not None: return suffix_match @@ -158,12 +171,18 @@ def __init__( # (including None) is cached. self._recorder_factory = recorder_factory self._resolved = recorder_factory is None + self._resolve_lock = threading.Lock() def _resolve_recorder(self) -> Optional[BillingRecorder]: - if not self._resolved: - factory = self._recorder_factory - self.recorder = factory() if factory is not None else self.recorder - self._resolved = True + if self._resolved: + return self.recorder + # The lock keeps concurrent first requests from each building their own + # MeterProvider (and leaking its background exporter thread). + with self._resolve_lock: + if not self._resolved: + factory = self._recorder_factory + self.recorder = factory() if factory is not None else self.recorder + self._resolved = True return self.recorder async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: @@ -195,4 +214,7 @@ async def send_wrapper(message: Message) -> None: await self.app(scope, receive, send_wrapper) if 200 <= status_code < 300: - recorder.record(category=category, route=route, status_code=status_code, model_id=model_id) + try: + recorder.record(category=category, route=route, status_code=status_code, model_id=model_id) + except Exception: # noqa: BLE001 -- metering must never fail a request that was already served + verbose_proxy_logger.warning("billable request metering failed for %s", route, exc_info=True) diff --git a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py index ae545d1b7598..b89ee9937422 100644 --- a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py +++ b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py @@ -95,6 +95,7 @@ def test_premium_with_full_config_builds_recorder(monkeypatch, tmp_path): premium=True, license_data={"user_id": "org-1"}, litellm_version="1.0" ) assert isinstance(recorder, bm.BillingMetricsRecorder) + recorder._provider.shutdown() # ── Config loading ──────────────────────────────────────────────────────────── @@ -106,6 +107,16 @@ def test_load_config_carries_license_id(monkeypatch, tmp_path): assert config is not None and config.license_id == "org-42" and config.litellm_version == "9.9" +def test_load_config_with_empty_string_env_is_disabled(monkeypatch, tmp_path): + """An env var set to the empty string is as unusable as an unset one and + must disable metering rather than produce a config with a blank endpoint.""" + paths = _write_certs(tmp_path) + monkeypatch.setenv(bm.ENDPOINT_ENV, "") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, paths[bm.CLIENT_CERT_ENV]) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, paths[bm.CLIENT_KEY_ENV]) + assert bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") is None + + def test_export_interval_default_and_override(monkeypatch): assert bm._export_interval_ms() == bm.DEFAULT_EXPORT_INTERVAL_MS monkeypatch.setenv(bm.EXPORT_INTERVAL_ENV, "5000") diff --git a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py index 2355fd406a25..a5b752814916 100644 --- a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py +++ b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py @@ -7,6 +7,7 @@ """ import asyncio +import threading from typing import List, Optional, Tuple import pytest @@ -89,7 +90,9 @@ def test_is_pure_asgi_not_base_http_middleware(): ("/images/edits", (BillableCategory.LLM, "/images/edits")), ("/openai/deployments/dall-e/images/edits", (BillableCategory.LLM, "/images/edits")), ("/v1/images/variations", (BillableCategory.LLM, "/images/variations")), - ("/v1/messages", (BillableCategory.LLM, "/messages")), + ("/v1/messages", (BillableCategory.LLM, "/v1/messages")), + ("/interactions", (BillableCategory.LLM, "/interactions")), + ("/v1beta/interactions", (BillableCategory.LLM, "/v1beta/interactions")), ("/v1/videos", (BillableCategory.LLM, "/videos")), ("/v1/videos/video_123/remix", (BillableCategory.LLM, "/remix")), ("/v1/ocr", (BillableCategory.LLM, "/ocr")), @@ -104,8 +107,8 @@ def test_is_pure_asgi_not_base_http_middleware(): ("/bedrock/model/anthropic.claude-v2/invoke", (BillableCategory.LLM, "/bedrock")), ("/vertex-ai/publishers/google/models/gemini:predict", (BillableCategory.LLM, "/vertex-ai")), ("/cohere/v2/chat", (BillableCategory.LLM, "/cohere")), - # Passthrough path ending in a known suffix keeps the finer-grained label - ("/anthropic/v1/messages", (BillableCategory.LLM, "/messages")), + # Passthrough inference bills under its provider prefix + ("/anthropic/v1/messages", (BillableCategory.LLM, "/anthropic")), ("/mcp", (BillableCategory.MCP, "/mcp")), ("/mcp/", (BillableCategory.MCP, "/mcp")), ("/mcp/tools/list", (BillableCategory.MCP, "/mcp")), @@ -134,6 +137,14 @@ def test_classify_billable(path: str, expected: Tuple[BillableCategory, str]): "/v1/files", # tokenization helper, not an inference call "/v1/messages/count_tokens", + # OpenAI Assistants thread messages write no SpendLogs row + "/v1/threads/thread_abc123/messages", + "/threads/thread_abc123/messages", + # Google Interactions reads and cancel are not inference calls + "/interactions/int_123", + "/v1beta/interactions/int_123", + "/interactions/int_123/cancel", + "/v1beta/interactions/int_123/cancel", # observability passthrough writes no SpendLogs row "/langfuse/api/public/ingestion", # a bare provider prefix is not an inference call @@ -276,6 +287,21 @@ def test_passthrough_when_recorder_is_none(): assert response.status_code == 200 +def test_record_raising_does_not_fail_the_request(): + """A broken exporter must never surface to the client: the response was + already served when record() runs, so exceptions are swallowed and logged.""" + + class ExplodingRecorder: + def record(self, *, category, route, status_code, model_id): + raise RuntimeError("exporter down") + + app = _make_app(None, status_code=200) + app.user_middleware.clear() + app.add_middleware(BillableRequestMetricsMiddleware, recorder=ExplodingRecorder()) + response = TestClient(app).post("/v1/chat/completions") + assert response.status_code == 200 + + def test_non_http_scope_is_ignored(): recorder = FakeRecorder() @@ -337,6 +363,31 @@ def factory(): assert calls == [1] +def test_recorder_factory_resolved_once_under_concurrency(): + """Concurrent first requests must not each build a recorder: every extra + build leaks a MeterProvider and its background exporter thread.""" + calls = [] + release = threading.Event() + + def slow_factory(): + calls.append(1) + release.wait(timeout=2) + return FakeRecorder() + + class _Inner: + async def __call__(self, scope, receive, send): + return None + + mw = BillableRequestMetricsMiddleware(_Inner(), recorder_factory=slow_factory) + threads = [threading.Thread(target=mw._resolve_recorder) for _ in range(8)] + for t in threads: + t.start() + release.set() + for t in threads: + t.join(timeout=5) + assert calls == [1] + + def _make_app_with_factory(factory, status_code: int) -> Starlette: async def handler(request: Request) -> Response: return JSONResponse({}, status_code=status_code) From 291cfbfa6649016ef9ce683c620899cc215e2508 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 10:43:07 +0300 Subject: [PATCH 08/29] chore(ui): regenerate eslint metrics after staging merge --- ui/litellm-dashboard/eslint-metrics.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index ded6ab97e1e8..37bad0710813 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { "@typescript-eslint/no-explicit-any": 1982, "complexity": 128, - "local/no-large-inline-object-arg": 512, + "local/no-large-inline-object-arg": 519, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 From b25c2d29999d652a808f15814de81953128ea65b Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 10:49:00 +0300 Subject: [PATCH 09/29] docs(proxy): state the lower-bound billing contract in middleware comments --- .../billable_request_metrics_middleware.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py index 6a3385702379..e7ab994acad9 100644 --- a/litellm/proxy/middleware/billable_request_metrics_middleware.py +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -33,12 +33,12 @@ def record(self, *, category: BillableCategory, route: str, status_code: int, mo _MODEL_ID_HEADER = b"x-litellm-model-id" # Ordered: a longer suffix that shares an ending with a shorter one must come -# first, e.g. "/chat/completions" before "/completions". This is the inference -# surface that writes a SpendLogs row on success -- the same population the -# admin UI usage page counts -- so the collector and the UI report the same -# number. LLM routes are POST-only inference calls; GET reads on the same -# resources (list/status/content) are not billable and are excluded by the -# method gate in classify_billable_request. +# first, e.g. "/chat/completions" before "/completions". This is the POST +# inference surface that writes a SpendLogs row on success, so the exported +# count lines up with the admin UI usage page for inference traffic. Billing +# is a deliberate lower bound on SpendLogs rows: management writes that also +# log (batch/file/fine-tuning creation, interaction cancel) and non-POST calls +# that log (passthrough reads) never bill, so drift only ever undercounts. _LLM_ROUTE_SUFFIXES: tuple[str, ...] = ( "/chat/completions", "/completions", @@ -130,8 +130,9 @@ def classify_billable_request(path: str, method: str = "POST") -> Optional[tuple if a2a_route is not None: return (BillableCategory.A2A, a2a_route) - # Inference calls are POSTs; GETs on these paths are reads (list videos, - # fetch a response object), which write no SpendLogs row and must not bill. + # POST-only is a conservative gate: non-POST calls can still write a + # SpendLogs row (passthrough reads, resource GETs) but must not bill, so + # any classifier-vs-dashboard mismatch is an undercount, never an overcount. if method.upper() != "POST": return None From 3271c0f7d49ae70f51730f10cd0db34fdf9f3ef8 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 11:09:18 +0300 Subject: [PATCH 10/29] 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 --- .../middleware/billable_request_metrics_middleware.py | 11 +++++++++++ .../test_billable_request_metrics_middleware.py | 8 +++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py index e7ab994acad9..d76abefef912 100644 --- a/litellm/proxy/middleware/billable_request_metrics_middleware.py +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -97,9 +97,16 @@ def _classify_llm_route(path: str) -> Optional[str]: _MCP_MANAGEMENT_PREFIX = "/v1/mcp" _MCP_DYNAMIC_TRANSPORT = re.compile(r"/(?:toolset/)?[^/]+/mcp") +# The REST wrapper's tool-call endpoint executes a tool and fires the same MCP +# spend logging as the /mcp transport; its list/test siblings do not bill. +_MCP_REST_TOOL_CALL = "/mcp-rest/tools/call" _A2A_INVOKE_SUFFIX = "/message/send" _A2A_TRANSPORT_PREFIXES: tuple[str, ...] = ("/v1/a2a/", "/a2a/") +# Bare POST /a2a/{agent_id} is the JSON-RPC invoke route (method message/send +# or message/stream travels in the body). /v1/a2a/discover has an extra path +# segment before the agent id, so this pattern cannot match it. +_A2A_BARE_INVOKE = re.compile(r"/a2a/[^/]+") def _classify_mcp_route(path: str) -> Optional[str]: @@ -107,6 +114,8 @@ def _classify_mcp_route(path: str) -> Optional[str]: return None if path == "/mcp" or path.startswith("/mcp/"): return "/mcp" + if path == _MCP_REST_TOOL_CALL: + return "/mcp" if _MCP_DYNAMIC_TRANSPORT.fullmatch(path) is not None: return "/mcp" return None @@ -115,6 +124,8 @@ def _classify_mcp_route(path: str) -> Optional[str]: def _classify_a2a_route(path: str) -> Optional[str]: if path.endswith(_A2A_INVOKE_SUFFIX) and any(path.startswith(prefix) for prefix in _A2A_TRANSPORT_PREFIXES): return "/a2a" + if _A2A_BARE_INVOKE.fullmatch(path) is not None: + return "/a2a" return None diff --git a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py index a5b752814916..453c4494a26f 100644 --- a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py +++ b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py @@ -116,8 +116,13 @@ def test_is_pure_asgi_not_base_http_middleware(): ("/github/mcp/", (BillableCategory.MCP, "/mcp")), ("/toolset/my-tools/mcp", (BillableCategory.MCP, "/mcp")), ("/github,slack/mcp", (BillableCategory.MCP, "/mcp")), + # REST wrapper tool execution fires the same MCP spend logging as /mcp + ("/mcp-rest/tools/call", (BillableCategory.MCP, "/mcp")), ("/a2a/agent-1/message/send", (BillableCategory.A2A, "/a2a")), ("/v1/a2a/agent-9/message/send", (BillableCategory.A2A, "/a2a")), + # bare invoke route: the JSON-RPC method (message/send or message/stream) + # travels in the body, not the path + ("/a2a/agent-1", (BillableCategory.A2A, "/a2a")), ], ) def test_classify_billable(path: str, expected: Tuple[BillableCategory, str]): @@ -160,7 +165,8 @@ def test_classify_billable(path: str, expected: Tuple[BillableCategory, str]): "/v1/a2a/discover", "/.well-known/oauth-protected-resource/github/mcp", "/mcp-rest/tools/list", - "/mcp-rest/tools/call", + "/mcp-rest/test/connection", + "/mcp-rest/test/tools/list", ], ) def test_classify_non_billable_returns_none(path: str): From 2e50567b8c32f349c0de81a4051eaef35cddfed9 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 11:17:17 +0300 Subject: [PATCH 11/29] 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 --- .../enterprise_billing/billing_metrics.py | 41 ++++++++++++++++++- litellm/proxy/proxy_server.py | 10 +++++ .../test_billing_metrics.py | 30 +++++++++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/enterprise_billing/billing_metrics.py b/litellm/proxy/enterprise_billing/billing_metrics.py index ea8c79509f6d..4b2a7a222328 100644 --- a/litellm/proxy/enterprise_billing/billing_metrics.py +++ b/litellm/proxy/enterprise_billing/billing_metrics.py @@ -37,6 +37,7 @@ CA_CERT_ENV = "LITELLM_BILLING_METRICS_CA_CERT" EXPORT_INTERVAL_ENV = "LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS" DEFAULT_EXPORT_INTERVAL_MS = 60_000 +SHUTDOWN_FLUSH_TIMEOUT_MS = 5_000 _METRICS_PATH = "/v1/metrics" METRIC_NAME = "litellm.enterprise.billable_requests" @@ -116,6 +117,11 @@ def __init__(self, provider: MeterProvider) -> None: def record(self, *, category: BillableCategory, route: str, status_code: int, model_id: Optional[str]) -> None: self._counter.add(1, _billable_attributes(category, route, status_code, model_id)) + def shutdown(self) -> None: + """Final flush + exporter-thread stop. Without this, up to one export + interval of billable counts is dropped on every proxy restart.""" + self._provider.shutdown(timeout_millis=SHUTDOWN_FLUSH_TIMEOUT_MS) + def _export_interval_ms() -> int: raw = os.getenv(EXPORT_INTERVAL_ENV) @@ -176,6 +182,26 @@ def load_billing_metrics_config( ) +class _ActiveRecorderRegistry: + """One-slot registry linking the factory-built recorder to the shutdown + hook; the middleware instance holding the recorder is not reachable from + proxy_shutdown_event.""" + + def __init__(self) -> None: + self._recorder: Optional[BillingMetricsRecorder] = None + + def set(self, recorder: BillingMetricsRecorder) -> None: + self._recorder = recorder + + def pop(self) -> Optional[BillingMetricsRecorder]: + recorder = self._recorder + self._recorder = None + return recorder + + +_ACTIVE_RECORDER = _ActiveRecorderRegistry() + + def build_billing_metrics_recorder( *, premium: bool, license_data: Optional["EnterpriseLicenseData"], litellm_version: str ) -> Optional[BillingMetricsRecorder]: @@ -188,7 +214,20 @@ def build_billing_metrics_recorder( return None try: - return BillingMetricsRecorder(build_mtls_meter_provider(config)) + recorder = BillingMetricsRecorder(build_mtls_meter_provider(config)) except Exception as exc: # noqa: BLE001 -- metering must never break proxy startup verbose_proxy_logger.warning("Enterprise billing metrics disabled: failed to initialize exporter: %s", exc) return None + _ACTIVE_RECORDER.set(recorder) + return recorder + + +def shutdown_billing_metrics_recorder() -> None: + """Flush and stop the active recorder, if any. Idempotent; never raises.""" + recorder = _ACTIVE_RECORDER.pop() + if recorder is None: + return + try: + recorder.shutdown() + except Exception as exc: # noqa: BLE001 -- shutdown must never block or fail proxy exit + verbose_proxy_logger.warning("Enterprise billing metrics: final flush failed: %s", exc) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4e1bb0dcfc13..e228a152884a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -447,10 +447,15 @@ def generate_feedback_box(): from litellm.proxy.enterprise_billing.billing_metrics import ( build_billing_metrics_recorder as _build_billing_metrics_recorder, ) + from litellm.proxy.enterprise_billing.billing_metrics import ( + shutdown_billing_metrics_recorder as _shutdown_billing_metrics_recorder, + ) build_billing_metrics_recorder: Optional[Callable[..., Optional[BillingRecorder]]] = _build_billing_metrics_recorder + shutdown_billing_metrics_recorder: Optional[Callable[[], None]] = _shutdown_billing_metrics_recorder except ImportError: build_billing_metrics_recorder = None + shutdown_billing_metrics_recorder = None from litellm.proxy.middleware.in_flight_requests_middleware import ( InFlightRequestsMiddleware, ) @@ -771,6 +776,11 @@ async def proxy_shutdown_event(): if db_writer_client is not None: await db_writer_client.close() # type: ignore[reportGeneralTypeIssues] + # final flush of billable-request counts: without it, up to one export + # interval of enterprise billing data is dropped on every restart + if shutdown_billing_metrics_recorder is not None: + shutdown_billing_metrics_recorder() + # flush remaining langfuse logs if "langfuse" in litellm.success_callback: try: diff --git a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py index b89ee9937422..66cd00f1fb28 100644 --- a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py +++ b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py @@ -31,6 +31,8 @@ def clear_env(monkeypatch): for name in _ENV_VARS: monkeypatch.delenv(name, raising=False) + yield + bm.shutdown_billing_metrics_recorder() def _write_certs(tmp_path: Path) -> Dict[str, str]: @@ -95,7 +97,33 @@ def test_premium_with_full_config_builds_recorder(monkeypatch, tmp_path): premium=True, license_data={"user_id": "org-1"}, litellm_version="1.0" ) assert isinstance(recorder, bm.BillingMetricsRecorder) - recorder._provider.shutdown() + bm.shutdown_billing_metrics_recorder() + + +def test_shutdown_flushes_active_recorder_once(monkeypatch, tmp_path): + """The shutdown hook must flush the recorder the factory built (buffered + counts are lost on restart otherwise) and be idempotent for repeat calls.""" + _set_full_env(monkeypatch, tmp_path) + shutdowns = [] + + class _SpyProvider: + def get_meter(self, name): + return MeterProvider().get_meter(name) + + def shutdown(self, timeout_millis=None): + shutdowns.append(timeout_millis) + + monkeypatch.setattr(bm, "build_mtls_meter_provider", lambda config: _SpyProvider()) + recorder = bm.build_billing_metrics_recorder(premium=True, license_data=None, litellm_version="1.0") + assert recorder is not None + + bm.shutdown_billing_metrics_recorder() + bm.shutdown_billing_metrics_recorder() + assert shutdowns == [bm.SHUTDOWN_FLUSH_TIMEOUT_MS] + + +def test_shutdown_without_active_recorder_is_noop(): + bm.shutdown_billing_metrics_recorder() # ── Config loading ──────────────────────────────────────────────────────────── From 382d11b961c7abc3148be08db5064c706b86d906 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 12:18:20 +0300 Subject: [PATCH 12/29] 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 --- .../billable_request_metrics_middleware.py | 14 +++-- litellm/proxy/proxy_server.py | 9 ++- ...est_billable_request_metrics_middleware.py | 61 ++++++++++++++++++- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py index d76abefef912..72bac1bcd6f4 100644 --- a/litellm/proxy/middleware/billable_request_metrics_middleware.py +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -103,10 +103,14 @@ def _classify_llm_route(path: str) -> Optional[str]: _A2A_INVOKE_SUFFIX = "/message/send" _A2A_TRANSPORT_PREFIXES: tuple[str, ...] = ("/v1/a2a/", "/a2a/") -# Bare POST /a2a/{agent_id} is the JSON-RPC invoke route (method message/send -# or message/stream travels in the body). /v1/a2a/discover has an extra path -# segment before the agent id, so this pattern cannot match it. -_A2A_BARE_INVOKE = re.compile(r"/a2a/[^/]+") +# Bare POST /a2a/{agent_id} carries the JSON-RPC method in the body, not the +# path. Only message/send and message/stream write a SpendLogs row there; the +# task RPCs (tasks/get, tasks/cancel, tasks/pushNotificationConfig/*, ...) are +# forwarded upstream and write none. A path-only classifier cannot separate +# them, so the bare route does not bill: counting a task RPC would overcount, +# while missing a bare-path message/send only undercounts, and undercounting is +# the sole direction this metric is allowed to drift. The /mcp transport is +# method-agnostic by contrast because its list path logs a SpendLogs row too. def _classify_mcp_route(path: str) -> Optional[str]: @@ -124,8 +128,6 @@ def _classify_mcp_route(path: str) -> Optional[str]: def _classify_a2a_route(path: str) -> Optional[str]: if path.endswith(_A2A_INVOKE_SUFFIX) and any(path.startswith(prefix) for prefix in _A2A_TRANSPORT_PREFIXES): return "/a2a" - if _A2A_BARE_INVOKE.fullmatch(path) is not None: - return "/a2a" return None diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index e228a152884a..010e2a73ee55 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1784,8 +1784,11 @@ def _restructure_ui_html_files(ui_root: str) -> None: ) app.add_middleware(PrometheusAuthMiddleware) -app.add_middleware(InFlightRequestsMiddleware) -app.add_middleware(SecurityHeadersMiddleware) +# Added before InFlightRequestsMiddleware so it nests *inside* it: Starlette +# makes the last-added middleware outermost. The billable count is recorded +# after the inner app returns, so if this sat outside the in-flight tracker a +# request could be counted as drained while its record() had not yet run, and +# proxy_shutdown_event could flush and stop the exporter underneath it. app.add_middleware( BillableRequestMetricsMiddleware, # Factory, not an instance: the recorder is resolved on the first request so @@ -1806,6 +1809,8 @@ def _restructure_ui_html_files(ui_root: str) -> None: else None ), ) +app.add_middleware(InFlightRequestsMiddleware) +app.add_middleware(SecurityHeadersMiddleware) def mount_swagger_ui(): diff --git a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py index 453c4494a26f..9363c50407d8 100644 --- a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py +++ b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py @@ -24,6 +24,9 @@ _extract_model_id, classify_billable_request, ) +from litellm.proxy.middleware.in_flight_requests_middleware import ( + InFlightRequestsMiddleware, +) class FakeRecorder: @@ -120,9 +123,6 @@ def test_is_pure_asgi_not_base_http_middleware(): ("/mcp-rest/tools/call", (BillableCategory.MCP, "/mcp")), ("/a2a/agent-1/message/send", (BillableCategory.A2A, "/a2a")), ("/v1/a2a/agent-9/message/send", (BillableCategory.A2A, "/a2a")), - # bare invoke route: the JSON-RPC method (message/send or message/stream) - # travels in the body, not the path - ("/a2a/agent-1", (BillableCategory.A2A, "/a2a")), ], ) def test_classify_billable(path: str, expected: Tuple[BillableCategory, str]): @@ -186,6 +186,25 @@ def test_classify_management_writes_are_not_billable(path: str): assert classify_billable_request(path, "POST") is None +@pytest.mark.parametrize( + "path", + [ + "/a2a/agent-1", + "/a2a/agent-1/", + "/v1/a2a/agent-1", + ], +) +def test_classify_bare_a2a_route_is_not_billable(path: str): + """ + The bare A2A route 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. Billing the path would count those task RPCs and push the metric + above the dashboard's successful-request count, so it must stay unbilled. + """ + assert classify_billable_request(path, "POST") is None + + @pytest.mark.parametrize( "path", ["/v1/videos", "/v1/responses", "/v1/chat/completions", "/v1/messages"], @@ -401,3 +420,39 @@ async def handler(request: Request) -> Response: app = Starlette(routes=[Route("/v1/chat/completions", handler, methods=["POST"])]) app.add_middleware(BillableRequestMetricsMiddleware, recorder_factory=factory) return app + + +# ── Shutdown ordering ─────────────────────────────────────────────────────── + + +def test_record_runs_before_request_leaves_the_in_flight_tracker(): + """ + The count is recorded after the inner app returns. If this middleware sat + outside InFlightRequestsMiddleware, a request could be seen as drained while + its record() had not run, letting proxy_shutdown_event flush and stop the + exporter underneath it. Nested inside, the in-flight count still covers it. + """ + observed: List[int] = [] + + class _CountingRecorder: + def record(self, *, category: BillableCategory, route: str, status_code: int, model_id: Optional[str]) -> None: + observed.append(InFlightRequestsMiddleware.get_count()) + + async def inner(scope, receive, send) -> None: + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"{}"}) + + stack = InFlightRequestsMiddleware(BillableRequestMetricsMiddleware(inner, recorder=_CountingRecorder())) + assert TestClient(stack).post("/v1/chat/completions").status_code == 200 + + assert observed == [1] + assert InFlightRequestsMiddleware.get_count() == 0 + + +def test_billable_middleware_is_registered_inside_the_in_flight_tracker(): + """Starlette makes the last-added middleware outermost, so the in-flight + tracker must be registered after the billing middleware to wrap it.""" + from litellm.proxy.proxy_server import app as proxy_app + + classes = [middleware.cls for middleware in proxy_app.user_middleware] + assert classes.index(InFlightRequestsMiddleware) < classes.index(BillableRequestMetricsMiddleware) From f0b217f2c26c60c515c5cf1c3dc425e8f1ab01b0 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 12:31:34 +0300 Subject: [PATCH 13/29] 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 --- .../test_billing_metrics.py | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py index 66cd00f1fb28..4d77ce7c2aee 100644 --- a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py +++ b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py @@ -8,8 +8,9 @@ expected OTLP counter via an in-memory reader. """ +import socket from pathlib import Path -from typing import Dict, Optional +from typing import Dict, List, Optional import pytest from opentelemetry.sdk.metrics import MeterProvider @@ -92,13 +93,30 @@ def test_premium_with_missing_cert_files_returns_none(monkeypatch, tmp_path): def test_premium_with_full_config_builds_recorder(monkeypatch, tmp_path): + """Builds a real MeterProvider, so the exporter is stubbed: the live one + resolves the collector and opens a TLS connection during the shutdown flush. + The getaddrinfo spy keeps that stub from being quietly dropped later.""" _set_full_env(monkeypatch, tmp_path) + monkeypatch.setattr(bm, "OTLPMetricExporter", _fake_exporter_class({})) + + resolved: List[str] = [] + real_getaddrinfo = socket.getaddrinfo + + def _spy_getaddrinfo(host, port, *args, **kwargs): + resolved.append(str(host)) + return real_getaddrinfo(host, port, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", _spy_getaddrinfo) + recorder = bm.build_billing_metrics_recorder( premium=True, license_data={"user_id": "org-1"}, litellm_version="1.0" ) assert isinstance(recorder, bm.BillingMetricsRecorder) + recorder.record(category=BillableCategory.LLM, route="/chat/completions", status_code=200, model_id=None) bm.shutdown_billing_metrics_recorder() + assert [host for host in resolved if "collector.example" in host] == [] + def test_shutdown_flushes_active_recorder_once(monkeypatch, tmp_path): """The shutdown hook must flush the recorder the factory built (buffered @@ -162,9 +180,11 @@ def test_metrics_endpoint_appends_signal_path(): assert bm._metrics_endpoint("https://telemetry.example.com/v1/metrics") == "https://telemetry.example.com/v1/metrics" -def test_meter_provider_wires_client_cert_into_http_exporter(tmp_path, monkeypatch): - """Client cert+key authenticate us at the collector's mTLS front end; CA override rides certificate_file.""" - captured = {} +def _fake_exporter_class(captured: Dict[str, object]) -> type: + """A no-network stand-in for OTLPMetricExporter. Tests that build a real + MeterProvider must install this: the real exporter resolves the collector + host and opens a TLS connection on the reader's first export and on the + shutdown flush.""" class _FakeExporter: # PeriodicExportingMetricReader probes these on the exporter it wraps. @@ -183,7 +203,14 @@ def shutdown(self, *args, **kwargs): def force_flush(self, *args, **kwargs): return True - monkeypatch.setattr(bm, "OTLPMetricExporter", _FakeExporter) + return _FakeExporter + + +def test_meter_provider_wires_client_cert_into_http_exporter(tmp_path, monkeypatch): + """Client cert+key authenticate us at the collector's mTLS front end; CA override rides certificate_file.""" + captured: Dict[str, object] = {} + + monkeypatch.setattr(bm, "OTLPMetricExporter", _fake_exporter_class(captured)) config = _config(tmp_path) provider = bm.build_mtls_meter_provider(config) provider.shutdown() From 4f7f706a6392d70aa9e158853a25a852b21c2137 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 19:38:16 +0300 Subject: [PATCH 14/29] 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--, 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 --- helm/litellm/templates/_helpers.tpl | 11 ++++- helm/litellm/tests/chart_label_tests.yaml | 60 +++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 helm/litellm/tests/chart_label_tests.yaml diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl index 4319907883e2..3523454c5b1c 100644 --- a/helm/litellm/templates/_helpers.tpl +++ b/helm/litellm/templates/_helpers.tpl @@ -27,11 +27,20 @@ Common naming + label helpers shared by gateway, backend, and ui templates. {{- printf "%s-ui" (include "litellm.fullname" .) | trunc 63 | trimSuffix "-" -}} {{- end -}} +{{/* +Chart label. Kubernetes caps a label value at 63 bytes, and .Chart.Version is +unbounded: CI branch builds version charts as 0.0.0-branch--, which +overflows and makes the API server reject every labeled resource. +*/}} +{{- define "litellm.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + {{- define "litellm.commonLabels" -}} app.kubernetes.io/name: {{ include "litellm.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} -helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +helm.sh/chart: {{ include "litellm.chart" . }} {{- end -}} {{/* diff --git a/helm/litellm/tests/chart_label_tests.yaml b/helm/litellm/tests/chart_label_tests.yaml new file mode 100644 index 000000000000..7bd0a4b9b4ea --- /dev/null +++ b/helm/litellm/tests/chart_label_tests.yaml @@ -0,0 +1,60 @@ +suite: test helm.sh/chart label stays within the 63 byte kubernetes limit +templates: + - gateway/deployment.yaml + - gateway/configmap.yaml + - backend/deployment.yaml + - ui/deployment.yaml + - migrations-job.yaml +values: + - ./values/required.yaml +tests: + - it: renders the plain chart label for a normal semver version + template: gateway/deployment.yaml + chart: + version: 0.1.0 + asserts: + - equal: + path: metadata.labels["helm.sh/chart"] + value: litellm-0.1.0 + + # CI publishes branch builds as 0.0.0-branch--. Untruncated, the + # label is 64 bytes and the API server rejects every labeled resource with + # "must be no more than 63 bytes", which wedges the whole release. + - it: truncates a long branch-build version to 63 bytes on the gateway + template: gateway/deployment.yaml + chart: + version: 0.0.0-branch-litellm-enterprise-request-metering-f0b217f + asserts: + - equal: + path: metadata.labels["helm.sh/chart"] + value: litellm-0.0.0-branch-litellm-enterprise-request-metering-f0b217 + + - it: truncates the long version on the migrations job that blocked the sync + template: migrations-job.yaml + chart: + version: 0.0.0-branch-litellm-enterprise-request-metering-f0b217f + asserts: + - equal: + path: metadata.labels["helm.sh/chart"] + value: litellm-0.0.0-branch-litellm-enterprise-request-metering-f0b217 + + - it: truncates the long version on backend and ui + templates: + - backend/deployment.yaml + - ui/deployment.yaml + chart: + version: 0.0.0-branch-litellm-enterprise-request-metering-f0b217f + asserts: + - equal: + path: metadata.labels["helm.sh/chart"] + value: litellm-0.0.0-branch-litellm-enterprise-request-metering-f0b217 + + # trunc can land on the separator; a label value may not end in a dash. + - it: never leaves a trailing dash after truncation + template: gateway/deployment.yaml + chart: + version: 0.0.0-branch-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbb + asserts: + - matchRegex: + path: metadata.labels["helm.sh/chart"] + pattern: "[^-]$" From 1b91dd4c0bfcacec9fbadfae1073593ce2c91000 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 19:41:30 +0300 Subject: [PATCH 15/29] 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 --- .../enterprise_billing/billing_metrics.py | 77 ++++++++++++++++- .../test_billing_metrics.py | 86 +++++++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/enterprise_billing/billing_metrics.py b/litellm/proxy/enterprise_billing/billing_metrics.py index 4b2a7a222328..e66a337f7a0c 100644 --- a/litellm/proxy/enterprise_billing/billing_metrics.py +++ b/litellm/proxy/enterprise_billing/billing_metrics.py @@ -14,6 +14,7 @@ """ import os +import tempfile from dataclasses import dataclass from typing import TYPE_CHECKING, Optional, Union @@ -40,6 +41,15 @@ SHUTDOWN_FLUSH_TIMEOUT_MS = 5_000 _METRICS_PATH = "/v1/metrics" +# The cert env vars take a path or the PEM itself. Secret stores that inject +# values as env content cannot mount them as files, so inline PEM is written out. +_PEM_PREFIX = "-----BEGIN" +_PEM_DIR_PREFIX = "litellm-billing-mtls-" +_PEM_FILE_MODE = 0o600 +_CLIENT_CERT_FILENAME = "client.crt" +_CLIENT_KEY_FILENAME = "client.key" +_CA_CERT_FILENAME = "ca.crt" + METRIC_NAME = "litellm.enterprise.billable_requests" METER_NAME = "litellm.enterprise.billing" @@ -136,6 +146,55 @@ def _export_interval_ms() -> int: return DEFAULT_EXPORT_INTERVAL_MS +@dataclass(frozen=True, slots=True) +class _CredentialPaths: + client_cert_path: str + client_key_path: str + ca_cert_path: Optional[str] + + +def _is_pem_content(value: str) -> bool: + return value.lstrip().startswith(_PEM_PREFIX) + + +def _write_pem(directory: str, filename: str, pem: str) -> str: + path = os.path.join(directory, filename) + with open(path, "w", encoding="utf-8") as handle: + handle.write(pem if pem.endswith("\n") else f"{pem}\n") + os.chmod(path, _PEM_FILE_MODE) + return path + + +def _resolve_credential_paths(*, client_cert: str, client_key: str, ca_cert: Optional[str]) -> _CredentialPaths: + """ + Accept either a filesystem path or inline PEM content for each credential. + + Secret stores that inject values as environment content rather than mounted + files (ECS tasks reading AWS Secrets Manager, Cloud Run reading Secret + Manager) can only deliver the certificate as a string. The OTLP exporter + takes paths, so inline PEM is written to a private directory once, when the + recorder is built. Raises OSError if that write fails; the caller disables + metering rather than propagating. + """ + inline = tuple(value for value in (client_cert, client_key, ca_cert) if value and _is_pem_content(value)) + if not inline: + return _CredentialPaths(client_cert, client_key, ca_cert) + + # mkdtemp is 0o700, so the 0o600 key file it holds is unreachable by other users. + directory = tempfile.mkdtemp(prefix=_PEM_DIR_PREFIX) + return _CredentialPaths( + client_cert_path=( + _write_pem(directory, _CLIENT_CERT_FILENAME, client_cert) if _is_pem_content(client_cert) else client_cert + ), + client_key_path=( + _write_pem(directory, _CLIENT_KEY_FILENAME, client_key) if _is_pem_content(client_key) else client_key + ), + ca_cert_path=( + _write_pem(directory, _CA_CERT_FILENAME, ca_cert) if ca_cert and _is_pem_content(ca_cert) else ca_cert + ), + ) + + def load_billing_metrics_config( *, license_data: Optional["EnterpriseLicenseData"], litellm_version: str ) -> Optional[BillingMetricsConfig]: @@ -162,7 +221,17 @@ def load_billing_metrics_config( ) return None - required_paths = [client_cert, client_key] + ([ca_cert] if ca_cert else []) + try: + paths = _resolve_credential_paths(client_cert=client_cert, client_key=client_key, ca_cert=ca_cert) + except OSError as exc: + verbose_proxy_logger.warning( + "Enterprise billing metrics disabled: could not write inline certificate content to disk: %s", exc + ) + return None + + required_paths = [paths.client_cert_path, paths.client_key_path] + ( + [paths.ca_cert_path] if paths.ca_cert_path else [] + ) unreadable = [path for path in required_paths if not os.path.isfile(path)] if unreadable: verbose_proxy_logger.warning( @@ -173,9 +242,9 @@ def load_billing_metrics_config( return BillingMetricsConfig( endpoint=endpoint, - client_cert_path=client_cert, - client_key_path=client_key, - ca_cert_path=ca_cert, + client_cert_path=paths.client_cert_path, + client_key_path=paths.client_key_path, + ca_cert_path=paths.ca_cert_path, export_interval_ms=_export_interval_ms(), litellm_version=litellm_version, license_id=(license_data or {}).get("user_id"), diff --git a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py index 4d77ce7c2aee..3666440ae167 100644 --- a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py +++ b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py @@ -8,7 +8,9 @@ expected OTLP counter via an in-memory reader. """ +import os import socket +import stat from pathlib import Path from typing import Dict, List, Optional @@ -163,6 +165,90 @@ def test_load_config_with_empty_string_env_is_disabled(monkeypatch, tmp_path): assert bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") is None +_CLIENT_CERT_PEM = "-----BEGIN CERTIFICATE-----\nclient-cert-body\n-----END CERTIFICATE-----" +_CLIENT_KEY_PEM = "-----BEGIN PRIVATE KEY-----\nclient-key-body\n-----END PRIVATE KEY-----" +_CA_CERT_PEM = "-----BEGIN CERTIFICATE-----\nca-body\n-----END CERTIFICATE-----" + + +def test_load_config_materializes_inline_pem_content(monkeypatch): + """ + ECS and Cloud Run inject secrets as env content, not as mounted files, so the + cert env vars must accept PEM directly. The exporter takes paths, so the PEM + is written to disk and the config points at those files. + """ + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, _CLIENT_CERT_PEM) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, _CLIENT_KEY_PEM) + monkeypatch.setenv(bm.CA_CERT_ENV, _CA_CERT_PEM) + + config = bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") + + assert config is not None + assert config.ca_cert_path is not None + written = { + config.client_cert_path: _CLIENT_CERT_PEM, + config.client_key_path: _CLIENT_KEY_PEM, + config.ca_cert_path: _CA_CERT_PEM, + } + for path, pem in written.items(): + assert path != pem, "config must carry a file path, not the PEM itself" + assert os.path.isfile(path) + assert Path(path).read_text(encoding="utf-8") == f"{pem}\n" + + # The private key must not be world- or group-readable. + assert stat.S_IMODE(os.stat(config.client_key_path).st_mode) == 0o600 + + +def test_load_config_accepts_a_mix_of_pem_content_and_file_paths(monkeypatch, tmp_path): + """A deployment may mount the CA but inject the client credentials inline.""" + paths = _write_certs(tmp_path) + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, _CLIENT_CERT_PEM) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, _CLIENT_KEY_PEM) + monkeypatch.setenv(bm.CA_CERT_ENV, paths[bm.CA_CERT_ENV]) + + config = bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") + + assert config is not None + assert config.ca_cert_path == paths[bm.CA_CERT_ENV] + assert Path(config.client_cert_path).read_text(encoding="utf-8") == f"{_CLIENT_CERT_PEM}\n" + + +def test_load_config_leaves_file_paths_untouched(monkeypatch, tmp_path): + """Path-valued env vars keep working; nothing is copied or rewritten.""" + paths = _set_full_env(monkeypatch, tmp_path) + + config = bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") + + assert config is not None + assert config.client_cert_path == paths[bm.CLIENT_CERT_ENV] + assert config.client_key_path == paths[bm.CLIENT_KEY_ENV] + assert config.ca_cert_path == paths[bm.CA_CERT_ENV] + + +def test_load_config_with_inline_pem_disabled_when_unwritable(monkeypatch): + """A failure to materialize the PEM disables metering instead of raising.""" + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, _CLIENT_CERT_PEM) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, _CLIENT_KEY_PEM) + + def _explode(prefix=None): + raise OSError("read-only filesystem") + + monkeypatch.setattr(bm.tempfile, "mkdtemp", _explode) + + assert bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") is None + + +def test_load_config_with_empty_pem_env_is_disabled(monkeypatch): + """Empty stays empty: an unset secret must not be mistaken for inline PEM.""" + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, "") + monkeypatch.setenv(bm.CLIENT_KEY_ENV, "") + + assert bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") is None + + def test_export_interval_default_and_override(monkeypatch): assert bm._export_interval_ms() == bm.DEFAULT_EXPORT_INTERVAL_MS monkeypatch.setenv(bm.EXPORT_INTERVAL_ENV, "5000") From 91f5e26ed56f134a787dad92e880953cae7c31eb Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 19:44:22 +0300 Subject: [PATCH 16/29] 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 --- helm/litellm/templates/_helpers.tpl | 48 +++++ .../litellm/templates/gateway/deployment.yaml | 13 +- helm/litellm/tests/billing_metrics_tests.yaml | 193 ++++++++++++++++++ helm/litellm/values.yaml | 14 ++ 4 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 helm/litellm/tests/billing_metrics_tests.yaml diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl index 3523454c5b1c..39002ab12022 100644 --- a/helm/litellm/templates/_helpers.tpl +++ b/helm/litellm/templates/_helpers.tpl @@ -43,6 +43,54 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} helm.sh/chart: {{ include "litellm.chart" . }} {{- end -}} +{{/* +Enterprise billable-request metering. Gateway only: the gateway is the component +that serves billable 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 }} +{{- 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. */}} diff --git a/helm/litellm/templates/gateway/deployment.yaml b/helm/litellm/templates/gateway/deployment.yaml index bd491b69e0f5..4c80d784156d 100644 --- a/helm/litellm/templates/gateway/deployment.yaml +++ b/helm/litellm/templates/gateway/deployment.yaml @@ -46,14 +46,20 @@ spec: - name: NUM_WORKERS value: {{ .Values.gateway.numWorkers | quote }} {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsEnv" . | nindent 12 }} + {{- end }} {{- include "litellm.envFrom" .Values.gateway | nindent 10 }} - {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts }} + {{- if or .Values.gateway.config.create .Values.gateway.volumeMounts .Values.billingMetrics.enabled }} 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.gateway.volumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} @@ -68,13 +74,16 @@ spec: {{- end }} resources: {{- toYaml .Values.gateway.resources | nindent 12 }} - {{- if or .Values.gateway.config.create .Values.gateway.volumes }} + {{- if or .Values.gateway.config.create .Values.gateway.volumes .Values.billingMetrics.enabled }} 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.gateway.volumes }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/helm/litellm/tests/billing_metrics_tests.yaml b/helm/litellm/tests/billing_metrics_tests.yaml new file mode 100644 index 000000000000..f1ff6c049bad --- /dev/null +++ b/helm/litellm/tests/billing_metrics_tests.yaml @@ -0,0 +1,193 @@ +suite: test billingMetrics wiring on the gateway +templates: + - gateway/deployment.yaml + - gateway/configmap.yaml + - backend/deployment.yaml +values: + - ./values/required.yaml +tests: + - it: is off by default, adding no env, volume, or mount + 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 + + - it: renders the endpoint and the mounted cert paths when enabled + template: gateway/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].env + content: + name: LITELLM_BILLING_METRICS_CLIENT_CERT + value: /etc/litellm/billing-mtls/tls.crt + - 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: + - contains: + path: spec.template.spec.volumes + 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: + - notContains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls-ca + secret: + secretName: billing-ca + - notContains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_CA_CERT + value: /etc/litellm/billing-mtls-ca/ca.crt + + - it: mounts the CA secret when caSecretName is set + template: gateway/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: + 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 + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + secretName: billing-mtls + exportIntervalMs: 5000 + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS + value: "5000" + + - it: keeps user-supplied gateway volumes alongside the billing secret + 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 + 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 + + # Only the gateway serves billable traffic; the backend must not mount the cert. + - it: does not touch the backend when enabled + template: backend/deployment.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 + + - it: fails loudly when enabled without a secretName + template: gateway/deployment.yaml + set: + billingMetrics: + enabled: true + 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 diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml index 6aa5dd39cd04..63d8046b249e 100644 --- a/helm/litellm/values.yaml +++ b/helm/litellm/values.yaml @@ -73,6 +73,20 @@ masterKey: secretName: litellm-master-key-secret # name of a Secret containing the master key secretKey: master-key +# Optional: enterprise billable-request metering. When enabled, the gateway +# 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. +billingMetrics: + enabled: false + endpoint: https://telemetry.litellm.ai # collector to push the counter to + secretName: "" # 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 + # External Postgres connection. database: writer: From 102307291e7cd748c9897fa6fe9802099f7204f3 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 19:54:16 +0300 Subject: [PATCH 17/29] feat(terraform): billing-metrics variables for the aws and gcp templates --- terraform/litellm/aws/README.md | 32 +++++++++++++++ terraform/litellm/aws/ecs.tf | 30 ++++++++++++++ terraform/litellm/aws/iam.tf | 3 ++ terraform/litellm/aws/secrets.tf | 55 ++++++++++++++++++++++++++ terraform/litellm/aws/variables.tf | 62 +++++++++++++++++++++++++++++ terraform/litellm/gcp/README.md | 34 ++++++++++++++++ terraform/litellm/gcp/cloudrun.tf | 41 +++++++++++++++++-- terraform/litellm/gcp/iam.tf | 26 ++++++++++++ terraform/litellm/gcp/secrets.tf | 55 ++++++++++++++++++++++++++ terraform/litellm/gcp/variables.tf | 63 ++++++++++++++++++++++++++++++ 10 files changed, 397 insertions(+), 4 deletions(-) diff --git a/terraform/litellm/aws/README.md b/terraform/litellm/aws/README.md index 7d4ef0a14fb7..40a9da66c708 100644 --- a/terraform/litellm/aws/README.md +++ b/terraform/litellm/aws/README.md @@ -158,6 +158,38 @@ AgentOps) live under `proxy_config.litellm_settings.callbacks` and are orthogonal to the OTLP variables above; their credentials still go in `*_extra_secrets`. +### Enterprise billing metrics + +License-gated request metering is opt-in and gated entirely on +`billing_metrics_endpoint`. Empty (default) and no billing env is added to +the container, so existing deployments are unchanged. Set it and both +gateway and backend export billable-request counts over OTLP/HTTP, +authenticating to the collector with the mTLS client certificate issued for +your deployment. + +The proxy accepts the certificate, key, and CA bundle as either a file path +or literal PEM content. This stack takes the PEM, writes each one to its own +Secrets Manager entry, grants the task-execution role +`secretsmanager:GetSecretValue` on them, and injects them as +`LITELLM_BILLING_METRICS_CLIENT_CERT` / `_CLIENT_KEY` (and `_CA_CERT` when +set), so no volume mount is needed on Fargate. + +```hcl +billing_metrics_endpoint = "https://telemetry.litellm.ai/v1/metrics" +``` + +```bash +export TF_VAR_billing_metrics_client_cert_pem="$(cat client.crt)" +export TF_VAR_billing_metrics_client_key_pem="$(cat client.key)" +``` + +`billing_metrics_ca_cert_pem` is only for private or test collectors whose +CA is not in the system trust store; leave it empty against +`telemetry.litellm.ai`. Metering requires an enterprise license, so pair +this with `litellm_license`. To tune the export cadence, set +`LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS` through `gateway_extra_env` / +`backend_extra_env` + ## Tenant deployment Every resource the stack creates is named `${tenant}-litellm-${env}` (or diff --git a/terraform/litellm/aws/ecs.tf b/terraform/litellm/aws/ecs.tf index 54ab80de9f48..bf0487c0a93f 100644 --- a/terraform/litellm/aws/ecs.tf +++ b/terraform/litellm/aws/ecs.tf @@ -76,6 +76,33 @@ locals { { name = "OTEL_HEADERS", valueFrom = var.otel_headers_secret_arn }, ] : [] + # Enterprise request metering, gated on billing_metrics_endpoint. The + # endpoint rides in as a plain env var; the mTLS material is stored in + # Secrets Manager (secrets.tf) and injected as PEM-valued env vars, which + # the proxy accepts in place of file paths. Each PEM is wired only when the + # operator supplied it, so an empty ca_cert_pem falls back to the system + # trust store. + billing_metrics_enabled = var.billing_metrics_endpoint != "" + billing_metrics_client_cert_enabled = local.billing_metrics_enabled && var.billing_metrics_client_cert_pem != "" + billing_metrics_client_key_enabled = local.billing_metrics_enabled && var.billing_metrics_client_key_pem != "" + billing_metrics_ca_cert_enabled = local.billing_metrics_enabled && var.billing_metrics_ca_cert_pem != "" + + billing_metrics_env = local.billing_metrics_enabled ? [ + { name = "LITELLM_BILLING_METRICS_ENDPOINT", value = var.billing_metrics_endpoint }, + ] : [] + + billing_metrics_secrets = concat( + local.billing_metrics_client_cert_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CLIENT_CERT", valueFrom = aws_secretsmanager_secret.billing_metrics_client_cert[0].arn }, + ] : [], + local.billing_metrics_client_key_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CLIENT_KEY", valueFrom = aws_secretsmanager_secret.billing_metrics_client_key[0].arn }, + ] : [], + local.billing_metrics_ca_cert_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CA_CERT", valueFrom = aws_secretsmanager_secret.billing_metrics_ca_cert[0].arn }, + ] : [], + ) + shared_env = [ { name = "IAM_TOKEN_DB_AUTH", value = "true" }, { name = "DATABASE_HOST", value = aws_rds_cluster.this.endpoint }, @@ -108,6 +135,7 @@ locals { { name = "LITELLM_LICENSE", valueFrom = aws_secretsmanager_secret.license[0].arn }, ], local.otel_secrets, + local.billing_metrics_secrets, ) # Backend-only managed secrets. UI_PASSWORD is consumed by the management @@ -198,6 +226,7 @@ resource "aws_ecs_task_definition" "gateway" { environment = concat( local.shared_env, local.gateway_otel_env, + local.billing_metrics_env, local.gateway_extra_env_list, local.proxy_config_env, ) @@ -284,6 +313,7 @@ resource "aws_ecs_task_definition" "backend" { local.shared_env, local.backend_default_env, local.backend_otel_env, + local.billing_metrics_env, local.backend_extra_env_list, local.proxy_config_env, ) diff --git a/terraform/litellm/aws/iam.tf b/terraform/litellm/aws/iam.tf index 64e1b1ad5f9a..63c6c26f1847 100644 --- a/terraform/litellm/aws/iam.tf +++ b/terraform/litellm/aws/iam.tf @@ -53,6 +53,9 @@ data "aws_iam_policy_document" "secrets_access" { [aws_secretsmanager_secret.master_key.arn], aws_secretsmanager_secret.license[*].arn, aws_secretsmanager_secret.ui_password[*].arn, + aws_secretsmanager_secret.billing_metrics_client_cert[*].arn, + aws_secretsmanager_secret.billing_metrics_client_key[*].arn, + aws_secretsmanager_secret.billing_metrics_ca_cert[*].arn, local.extra_secret_arns, var.otel_headers_secret_arn == "" ? [] : [var.otel_headers_secret_arn], ) diff --git a/terraform/litellm/aws/secrets.tf b/terraform/litellm/aws/secrets.tf index 300d38e40534..85d3eb4502cf 100644 --- a/terraform/litellm/aws/secrets.tf +++ b/terraform/litellm/aws/secrets.tf @@ -74,6 +74,61 @@ resource "aws_secretsmanager_secret_version" "ui_password" { secret_string = var.ui_password } +# Billing-metrics mTLS material — only created when metering is enabled +# (billing_metrics_endpoint non-empty) and the operator supplied the PEM. +# The task-execution role gets GetSecretValue via iam.tf, and gateway + +# backend pick the env vars up through shared_secrets in ecs.tf. +resource "aws_secretsmanager_secret" "billing_metrics_client_cert" { + count = local.billing_metrics_client_cert_enabled ? 1 : 0 + + name = "${local.name}-billing-metrics-client-cert" + description = "LITELLM_BILLING_METRICS_CLIENT_CERT for gateway + backend." + recovery_window_in_days = 0 + + tags = local.tags +} + +resource "aws_secretsmanager_secret_version" "billing_metrics_client_cert" { + count = local.billing_metrics_client_cert_enabled ? 1 : 0 + + secret_id = aws_secretsmanager_secret.billing_metrics_client_cert[0].id + secret_string = var.billing_metrics_client_cert_pem +} + +resource "aws_secretsmanager_secret" "billing_metrics_client_key" { + count = local.billing_metrics_client_key_enabled ? 1 : 0 + + name = "${local.name}-billing-metrics-client-key" + description = "LITELLM_BILLING_METRICS_CLIENT_KEY for gateway + backend." + recovery_window_in_days = 0 + + tags = local.tags +} + +resource "aws_secretsmanager_secret_version" "billing_metrics_client_key" { + count = local.billing_metrics_client_key_enabled ? 1 : 0 + + secret_id = aws_secretsmanager_secret.billing_metrics_client_key[0].id + secret_string = var.billing_metrics_client_key_pem +} + +resource "aws_secretsmanager_secret" "billing_metrics_ca_cert" { + count = local.billing_metrics_ca_cert_enabled ? 1 : 0 + + name = "${local.name}-billing-metrics-ca-cert" + description = "LITELLM_BILLING_METRICS_CA_CERT for gateway + backend." + recovery_window_in_days = 0 + + tags = local.tags +} + +resource "aws_secretsmanager_secret_version" "billing_metrics_ca_cert" { + count = local.billing_metrics_ca_cert_enabled ? 1 : 0 + + secret_id = aws_secretsmanager_secret.billing_metrics_ca_cert[0].id + secret_string = var.billing_metrics_ca_cert_pem +} + resource "aws_secretsmanager_secret" "db_master_password" { name = "${local.name}-db-master-password" description = "Aurora master-user password - bootstrap only. Runtime auth is IAM-token." diff --git a/terraform/litellm/aws/variables.tf b/terraform/litellm/aws/variables.tf index 8db4935664bf..c2ed1db14b1e 100644 --- a/terraform/litellm/aws/variables.tf +++ b/terraform/litellm/aws/variables.tf @@ -533,3 +533,65 @@ variable "otel_headers_secret_arn" { type = string default = "" } + +# ---------- Enterprise billing metrics ---------- +# +# License-gated request metering. Opt-in and gated entirely on +# billing_metrics_endpoint: leave it empty (the default) and nothing +# metering-related lands in the container env. Set it and gateway + backend +# export billable-request counts over OTLP/HTTP, authenticating to the +# collector with an mTLS client cert. The proxy accepts the cert, key, and CA +# as either a file path or literal PEM content, so on Fargate they are +# injected straight from Secrets Manager as env vars and no volume is needed. + +variable "billing_metrics_endpoint" { + description = <<-EOT + OTLP/HTTP endpoint for enterprise billing metrics (sets + LITELLM_BILLING_METRICS_ENDPOINT). Non-empty enables request metering; + empty (default) disables it and adds no billing env to the container. + Requires an enterprise license. Example: + "https://telemetry.litellm.ai/v1/metrics" + EOT + type = string + default = "" +} + +variable "billing_metrics_client_cert_pem" { + description = <<-EOT + PEM content of the mTLS client certificate issued for this deployment. + When billing_metrics_endpoint is set, the stack stores this in a + `-litellm--billing-metrics-client-cert` Secrets Manager + entry, grants the task-execution role GetSecretValue on it, and exposes + it to gateway + backend as LITELLM_BILLING_METRICS_CLIENT_CERT. Required + whenever metering is enabled. + EOT + type = string + default = "" + sensitive = true +} + +variable "billing_metrics_client_key_pem" { + description = <<-EOT + PEM content of the private key matching + billing_metrics_client_cert_pem. Stored in a + `-litellm--billing-metrics-client-key` Secrets Manager + entry and exposed as LITELLM_BILLING_METRICS_CLIENT_KEY. Required + whenever metering is enabled. + EOT + type = string + default = "" + sensitive = true +} + +variable "billing_metrics_ca_cert_pem" { + description = <<-EOT + PEM content of the CA bundle used to verify the metering collector. + Only needed for private or test collectors whose CA is not in the + system trust store; telemetry.litellm.ai is publicly trusted, so leave + this empty for production. When set, it is exposed as + LITELLM_BILLING_METRICS_CA_CERT. + EOT + type = string + default = "" + sensitive = true +} diff --git a/terraform/litellm/gcp/README.md b/terraform/litellm/gcp/README.md index 1e0bf4319df3..88e9979148fe 100644 --- a/terraform/litellm/gcp/README.md +++ b/terraform/litellm/gcp/README.md @@ -204,6 +204,40 @@ Behavior matches the AWS stack 1:1; the only naming differences are `otel_headers_secret` (a Secret Manager resource ID) vs AWS's `otel_headers_secret_arn` (a Secrets Manager ARN). +### Enterprise billing metrics + +License-gated request metering is opt-in and gated entirely on +`billing_metrics_endpoint`. Empty (default) and no billing env is added to +the container, so existing deployments are unchanged. Set it and both +gateway and backend export billable-request counts over OTLP/HTTP, +authenticating to the collector with the mTLS client certificate issued for +your deployment. + +The proxy accepts the certificate, key, and CA bundle as either a file path +or literal PEM content. This stack takes the PEM, writes each one to its own +Secret Manager entry, grants the runtime service account +`roles/secretmanager.secretAccessor` on them, and injects them as Cloud Run +secret env vars `LITELLM_BILLING_METRICS_CLIENT_CERT` / `_CLIENT_KEY` (and +`_CA_CERT` when set), so no volume mount is needed. + +```hcl +billing_metrics_endpoint = "https://telemetry.litellm.ai/v1/metrics" +``` + +```bash +export TF_VAR_billing_metrics_client_cert_pem="$(cat client.crt)" +export TF_VAR_billing_metrics_client_key_pem="$(cat client.key)" +``` + +`billing_metrics_ca_cert_pem` is only for private or test collectors whose +CA is not in the system trust store; leave it empty against +`telemetry.litellm.ai`. Metering requires an enterprise license, so pair +this with `litellm_license`. To tune the export cadence, set +`LITELLM_BILLING_METRICS_EXPORT_INTERVAL_MS` through `gateway_extra_env` / +`backend_extra_env` + +Behavior matches the AWS stack 1:1; the variable names are identical + ## Tenant deployment Every resource the stack creates is named `${tenant}-litellm-${env}` (or diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index 7b1bb901e206..913ec2bbb3d8 100644 --- a/terraform/litellm/gcp/cloudrun.tf +++ b/terraform/litellm/gcp/cloudrun.tf @@ -59,6 +59,33 @@ locals { { name = "OTEL_HEADERS", secret = var.otel_headers_secret, version = "latest" }, ] : [] + # Enterprise request metering, gated on billing_metrics_endpoint. The + # endpoint rides in as a plain env var; the mTLS material lives in Secret + # Manager (secrets.tf) and is injected as PEM-valued env vars, which the + # proxy accepts in place of file paths. Each PEM is wired only when the + # operator supplied it, so an empty ca_cert_pem falls back to the system + # trust store. + billing_metrics_enabled = var.billing_metrics_endpoint != "" + billing_metrics_client_cert_enabled = local.billing_metrics_enabled && var.billing_metrics_client_cert_pem != "" + billing_metrics_client_key_enabled = local.billing_metrics_enabled && var.billing_metrics_client_key_pem != "" + billing_metrics_ca_cert_enabled = local.billing_metrics_enabled && var.billing_metrics_ca_cert_pem != "" + + billing_metrics_env_kv = local.billing_metrics_enabled ? [ + { name = "LITELLM_BILLING_METRICS_ENDPOINT", value = var.billing_metrics_endpoint }, + ] : [] + + billing_metrics_env_secrets = concat( + local.billing_metrics_client_cert_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CLIENT_CERT", secret = google_secret_manager_secret.billing_metrics_client_cert[0].id, version = "latest" }, + ] : [], + local.billing_metrics_client_key_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CLIENT_KEY", secret = google_secret_manager_secret.billing_metrics_client_key[0].id, version = "latest" }, + ] : [], + local.billing_metrics_ca_cert_enabled ? [ + { name = "LITELLM_BILLING_METRICS_CA_CERT", secret = google_secret_manager_secret.billing_metrics_ca_cert[0].id, version = "latest" }, + ] : [], + ) + # Cloud Run v2 secret env vars use value_source.secret_key_ref pointing at a # secret resource ID. Shared between gateway and backend (the migrations # job has its own narrower env list — see migrations_env_secrets below). @@ -175,7 +202,7 @@ resource "google_cloud_run_v2_service" "gateway" { } dynamic "env" { - for_each = concat(local.shared_env_kv, local.gateway_otel_env_kv, local.gateway_extra_env_kv, local.proxy_config_env) + for_each = concat(local.shared_env_kv, local.gateway_otel_env_kv, local.billing_metrics_env_kv, local.gateway_extra_env_kv, local.proxy_config_env) content { name = env.value.name value = env.value.value @@ -183,7 +210,7 @@ resource "google_cloud_run_v2_service" "gateway" { } dynamic "env" { - for_each = concat(local.shared_env_secrets, local.otel_env_secrets, local.gateway_extra_secret_kv) + for_each = concat(local.shared_env_secrets, local.otel_env_secrets, local.billing_metrics_env_secrets, local.gateway_extra_secret_kv) content { name = env.value.name value_source { @@ -242,6 +269,9 @@ resource "google_cloud_run_v2_service" "gateway" { google_secret_manager_secret_iam_member.license, google_secret_manager_secret_iam_member.extras, google_secret_manager_secret_iam_member.otel_headers, + google_secret_manager_secret_iam_member.billing_metrics_client_cert, + google_secret_manager_secret_iam_member.billing_metrics_client_key, + google_secret_manager_secret_iam_member.billing_metrics_ca_cert, google_storage_bucket_iam_member.proxy_config_runtime, google_sql_user.app, # Don't go live until the schema is migrated; otherwise the proxy boots, @@ -289,7 +319,7 @@ resource "google_cloud_run_v2_service" "backend" { } dynamic "env" { - for_each = concat(local.shared_env_kv, local.backend_default_env_kv, local.backend_otel_env_kv, local.backend_extra_env_kv, local.proxy_config_env) + for_each = concat(local.shared_env_kv, local.backend_default_env_kv, local.backend_otel_env_kv, local.billing_metrics_env_kv, local.backend_extra_env_kv, local.proxy_config_env) content { name = env.value.name value = env.value.value @@ -297,7 +327,7 @@ resource "google_cloud_run_v2_service" "backend" { } dynamic "env" { - for_each = concat(local.shared_env_secrets, local.backend_managed_env_secrets, local.otel_env_secrets, local.backend_extra_secret_kv) + for_each = concat(local.shared_env_secrets, local.backend_managed_env_secrets, local.otel_env_secrets, local.billing_metrics_env_secrets, local.backend_extra_secret_kv) content { name = env.value.name value_source { @@ -357,6 +387,9 @@ resource "google_cloud_run_v2_service" "backend" { google_secret_manager_secret_iam_member.ui_password, google_secret_manager_secret_iam_member.extras, google_secret_manager_secret_iam_member.otel_headers, + google_secret_manager_secret_iam_member.billing_metrics_client_cert, + google_secret_manager_secret_iam_member.billing_metrics_client_key, + google_secret_manager_secret_iam_member.billing_metrics_ca_cert, google_storage_bucket_iam_member.proxy_config_runtime, google_sql_user.app, terraform_data.migration, diff --git a/terraform/litellm/gcp/iam.tf b/terraform/litellm/gcp/iam.tf index dc3ae5e0912b..09df5e7dff06 100644 --- a/terraform/litellm/gcp/iam.tf +++ b/terraform/litellm/gcp/iam.tf @@ -79,3 +79,29 @@ resource "google_secret_manager_secret_iam_member" "otel_headers" { role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${google_service_account.runtime.email}" } + +# Billing-metrics mTLS accessors — only created when request metering is +# enabled and the matching PEM was supplied. +resource "google_secret_manager_secret_iam_member" "billing_metrics_client_cert" { + count = local.billing_metrics_client_cert_enabled ? 1 : 0 + + secret_id = google_secret_manager_secret.billing_metrics_client_cert[0].id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.runtime.email}" +} + +resource "google_secret_manager_secret_iam_member" "billing_metrics_client_key" { + count = local.billing_metrics_client_key_enabled ? 1 : 0 + + secret_id = google_secret_manager_secret.billing_metrics_client_key[0].id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.runtime.email}" +} + +resource "google_secret_manager_secret_iam_member" "billing_metrics_ca_cert" { + count = local.billing_metrics_ca_cert_enabled ? 1 : 0 + + secret_id = google_secret_manager_secret.billing_metrics_ca_cert[0].id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.runtime.email}" +} diff --git a/terraform/litellm/gcp/secrets.tf b/terraform/litellm/gcp/secrets.tf index f93514bb70b9..6ec771399966 100644 --- a/terraform/litellm/gcp/secrets.tf +++ b/terraform/litellm/gcp/secrets.tf @@ -63,3 +63,58 @@ resource "google_secret_manager_secret_version" "ui_password" { secret = google_secret_manager_secret.ui_password[0].id secret_data = var.ui_password } + +# Billing-metrics mTLS material — only created when metering is enabled +# (billing_metrics_endpoint non-empty) and the operator supplied the PEM. +# The runtime SA gets accessor permission via iam.tf, and gateway + backend +# pick the env vars up through billing_metrics_env_secrets in cloudrun.tf. +resource "google_secret_manager_secret" "billing_metrics_client_cert" { + count = local.billing_metrics_client_cert_enabled ? 1 : 0 + + secret_id = "${local.name}-billing-metrics-client-cert" + labels = local.labels + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "billing_metrics_client_cert" { + count = local.billing_metrics_client_cert_enabled ? 1 : 0 + + secret = google_secret_manager_secret.billing_metrics_client_cert[0].id + secret_data = var.billing_metrics_client_cert_pem +} + +resource "google_secret_manager_secret" "billing_metrics_client_key" { + count = local.billing_metrics_client_key_enabled ? 1 : 0 + + secret_id = "${local.name}-billing-metrics-client-key" + labels = local.labels + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "billing_metrics_client_key" { + count = local.billing_metrics_client_key_enabled ? 1 : 0 + + secret = google_secret_manager_secret.billing_metrics_client_key[0].id + secret_data = var.billing_metrics_client_key_pem +} + +resource "google_secret_manager_secret" "billing_metrics_ca_cert" { + count = local.billing_metrics_ca_cert_enabled ? 1 : 0 + + secret_id = "${local.name}-billing-metrics-ca-cert" + labels = local.labels + replication { + auto {} + } +} + +resource "google_secret_manager_secret_version" "billing_metrics_ca_cert" { + count = local.billing_metrics_ca_cert_enabled ? 1 : 0 + + secret = google_secret_manager_secret.billing_metrics_ca_cert[0].id + secret_data = var.billing_metrics_ca_cert_pem +} diff --git a/terraform/litellm/gcp/variables.tf b/terraform/litellm/gcp/variables.tf index 4355192e9f16..1162e100bb24 100644 --- a/terraform/litellm/gcp/variables.tf +++ b/terraform/litellm/gcp/variables.tf @@ -490,3 +490,66 @@ variable "otel_capture_message_content" { error_message = "otel_capture_message_content must be one of: no_content, prompt_and_completion." } } + +# ---------- Enterprise billing metrics ---------- +# +# License-gated request metering. Opt-in and gated entirely on +# billing_metrics_endpoint: leave it empty (the default) and nothing +# metering-related is added to the container env. Set it and gateway + +# backend export billable-request counts over OTLP/HTTP, authenticating to +# the collector with an mTLS client cert. The proxy accepts the cert, key, +# and CA as either a file path or literal PEM content, so on Cloud Run they +# are injected straight from Secret Manager as env vars and no volume is +# needed. + +variable "billing_metrics_endpoint" { + description = <<-EOT + OTLP/HTTP endpoint for enterprise billing metrics (sets + LITELLM_BILLING_METRICS_ENDPOINT). Non-empty enables request metering; + empty (default) disables it and adds no billing env to the container. + Requires an enterprise license. Example: + "https://telemetry.litellm.ai/v1/metrics" + EOT + type = string + default = "" +} + +variable "billing_metrics_client_cert_pem" { + description = <<-EOT + PEM content of the mTLS client certificate issued for this deployment. + When billing_metrics_endpoint is set, the stack stores this in a + `-litellm--billing-metrics-client-cert` Secret Manager + entry, grants the runtime SA accessor on it, and exposes it to gateway + + backend as LITELLM_BILLING_METRICS_CLIENT_CERT. Required whenever + metering is enabled. + EOT + type = string + default = "" + sensitive = true +} + +variable "billing_metrics_client_key_pem" { + description = <<-EOT + PEM content of the private key matching + billing_metrics_client_cert_pem. Stored in a + `-litellm--billing-metrics-client-key` Secret Manager entry + and exposed as LITELLM_BILLING_METRICS_CLIENT_KEY. Required whenever + metering is enabled. + EOT + type = string + default = "" + sensitive = true +} + +variable "billing_metrics_ca_cert_pem" { + description = <<-EOT + PEM content of the CA bundle used to verify the metering collector. + Only needed for private or test collectors whose CA is not in the + system trust store; telemetry.litellm.ai is publicly trusted, so leave + this empty for production. When set, it is exposed as + LITELLM_BILLING_METRICS_CA_CERT. + EOT + type = string + default = "" + sensitive = true +} From f77c13edf4a2537454b4de0ecfda1b329d1b9ea4 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 19:54:47 +0300 Subject: [PATCH 18/29] 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 --- helm/litellm-helm/README.md | 21 ++ helm/litellm-helm/templates/_helpers.tpl | 47 +++ helm/litellm-helm/templates/deployment.yaml | 9 + .../tests/billing_metrics_tests.yaml | 277 ++++++++++++++++++ helm/litellm-helm/values.yaml | 14 + 5 files changed, 368 insertions(+) create mode 100644 helm/litellm-helm/tests/billing_metrics_tests.yaml diff --git a/helm/litellm-helm/README.md b/helm/litellm-helm/README.md index 74e70f4aeb4f..0edc4d2504b2 100644 --- a/helm/litellm-helm/README.md +++ b/helm/litellm-helm/README.md @@ -54,6 +54,12 @@ If `db.useStackgresOperator` is used (not yet implemented): | `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): ``` @@ -94,6 +100,21 @@ data: 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 | diff --git a/helm/litellm-helm/templates/_helpers.tpl b/helm/litellm-helm/templates/_helpers.tpl index 25b02dd5f37a..f635ef9b9388 100644 --- a/helm/litellm-helm/templates/_helpers.tpl +++ b/helm/litellm-helm/templates/_helpers.tpl @@ -50,6 +50,53 @@ 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 }} +{{- 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 */}} diff --git a/helm/litellm-helm/templates/deployment.yaml b/helm/litellm-helm/templates/deployment.yaml index b9cd1be06ec9..32bfa4b26473 100644 --- a/helm/litellm-helm/templates/deployment.yaml +++ b/helm/litellm-helm/templates/deployment.yaml @@ -142,6 +142,9 @@ spec: {{- with .Values.extraEnvVars }} {{- toYaml . | nindent 12 }} {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsEnv" . | nindent 12 }} + {{- end }} {{- if .Values.migrationJob.enabled }} # Schema updates are owned by the dedicated migrations Job; skip # the proxy's startup `prisma db push` so N replicas don't race @@ -220,6 +223,9 @@ spec: - name: npm mountPath: /.npm {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsVolumeMounts" . | nindent 12 }} + {{- end }} {{- with .Values.volumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} @@ -252,6 +258,9 @@ spec: items: - key: {{ .Values.proxyConfigMap.key | default "config.yaml" }} path: "config.yaml" + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsVolumes" . | nindent 8 }} + {{- end }} {{- with .Values.volumes }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/helm/litellm-helm/tests/billing_metrics_tests.yaml b/helm/litellm-helm/tests/billing_metrics_tests.yaml new file mode 100644 index 000000000000..da96824c30a7 --- /dev/null +++ b/helm/litellm-helm/tests/billing_metrics_tests.yaml @@ -0,0 +1,277 @@ +suite: test billingMetrics wiring on the proxy deployment +templates: + - deployment.yaml + - configmap-litellm.yaml +tests: + - it: is off by default, adding no env, volume, or mount + 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 + + - it: renders the endpoint and the mounted cert paths when enabled + template: deployment.yaml + set: + billingMetrics: + enabled: true + 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].env + content: + name: LITELLM_BILLING_METRICS_CLIENT_CERT + value: /etc/litellm/billing-mtls/tls.crt + - 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: + - 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: + - notContains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls-ca + secret: + secretName: billing-ca + - 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 + + - 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 + template: deployment.yaml + set: + billingMetrics: + enabled: true + exportIntervalMs: 5000 + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + 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 + + - 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 + template: deployment.yaml + set: + billingMetrics: + enabled: true + endpoint: "" + asserts: + - failedTemplate: + errorMessage: billingMetrics.endpoint is required when billingMetrics.enabled is true diff --git a/helm/litellm-helm/values.yaml b/helm/litellm-helm/values.yaml index 6e30a6af444e..7dc07cabc1db 100644 --- a/helm/litellm-helm/values.yaml +++ b/helm/litellm-helm/values.yaml @@ -139,6 +139,20 @@ 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. +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 + proxyConfigMap: # when true, creates a new configmap create: true From a5b0f7cf2cf4e720a067a5a444b1f3c996a61c0f Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 19:57:39 +0300 Subject: [PATCH 19/29] 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. --- .../tests/billing_metrics_tests.yaml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/helm/litellm-helm/tests/billing_metrics_tests.yaml b/helm/litellm-helm/tests/billing_metrics_tests.yaml index da96824c30a7..71803df378c9 100644 --- a/helm/litellm-helm/tests/billing_metrics_tests.yaml +++ b/helm/litellm-helm/tests/billing_metrics_tests.yaml @@ -2,6 +2,7 @@ suite: test billingMetrics wiring on the proxy deployment templates: - deployment.yaml - configmap-litellm.yaml + - migrations-job.yaml tests: - it: is off by default, adding no env, volume, or mount template: deployment.yaml @@ -256,6 +257,25 @@ tests: 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: From 37c6bad28985251ad544de8b7dbc049d99db5bbc Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 20:08:00 +0300 Subject: [PATCH 20/29] 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. --- helm/litellm/templates/_helpers.tpl | 11 +++-- .../litellm/templates/backend/deployment.yaml | 13 +++++- helm/litellm/tests/billing_metrics_tests.yaml | 46 +++++++++++++++++-- helm/litellm/values.yaml | 11 +++-- 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl index 39002ab12022..80ab3d1e96ef 100644 --- a/helm/litellm/templates/_helpers.tpl +++ b/helm/litellm/templates/_helpers.tpl @@ -44,10 +44,13 @@ helm.sh/chart: {{ include "litellm.chart" . }} {{- end -}} {{/* -Enterprise billable-request metering. Gateway only: the gateway is the component -that serves billable 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. +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 -}} diff --git a/helm/litellm/templates/backend/deployment.yaml b/helm/litellm/templates/backend/deployment.yaml index 8b4552bf302b..9d056167fe14 100644 --- a/helm/litellm/templates/backend/deployment.yaml +++ b/helm/litellm/templates/backend/deployment.yaml @@ -44,14 +44,20 @@ spec: - name: CONFIG_FILE_PATH value: /app/config/config.yaml {{- end }} + {{- if .Values.billingMetrics.enabled }} + {{- include "litellm.billingMetricsEnv" . | nindent 12 }} + {{- end }} {{- include "litellm.envFrom" .Values.backend | nindent 10 }} - {{- if or .Values.gateway.config.create .Values.backend.volumeMounts }} + {{- if or .Values.gateway.config.create .Values.backend.volumeMounts .Values.billingMetrics.enabled }} 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 }} @@ -66,13 +72,16 @@ spec: {{- end }} resources: {{- toYaml .Values.backend.resources | nindent 12 }} - {{- if or .Values.gateway.config.create .Values.backend.volumes }} + {{- if or .Values.gateway.config.create .Values.backend.volumes .Values.billingMetrics.enabled }} 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 }} diff --git a/helm/litellm/tests/billing_metrics_tests.yaml b/helm/litellm/tests/billing_metrics_tests.yaml index f1ff6c049bad..c85fc9dfe42d 100644 --- a/helm/litellm/tests/billing_metrics_tests.yaml +++ b/helm/litellm/tests/billing_metrics_tests.yaml @@ -1,8 +1,9 @@ -suite: test billingMetrics wiring on the gateway +suite: test billingMetrics wiring on gateway and backend templates: - gateway/deployment.yaml - gateway/configmap.yaml - backend/deployment.yaml + - migrations-job.yaml values: - ./values/required.yaml tests: @@ -158,9 +159,46 @@ tests: secret: secretName: billing-mtls - # Only the gateway serves billable traffic; the backend must not mount the cert. - - it: does not touch the backend when enabled + # 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 @@ -171,6 +209,8 @@ tests: content: name: LITELLM_BILLING_METRICS_ENDPOINT value: https://telemetry.litellm.ai + - isNull: + path: spec.template.spec.volumes - it: fails loudly when enabled without a secretName template: gateway/deployment.yaml diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml index 63d8046b249e..b3b72c9530a4 100644 --- a/helm/litellm/values.yaml +++ b/helm/litellm/values.yaml @@ -73,11 +73,12 @@ masterKey: secretName: litellm-master-key-secret # name of a Secret containing the master key secretKey: master-key -# Optional: enterprise billable-request metering. When enabled, the gateway -# 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. 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. billingMetrics: enabled: false endpoint: https://telemetry.litellm.ai # collector to push the counter to From 269ee7e876848567c3bfcb083e3bd85949ede9d0 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 20:32:30 +0300 Subject: [PATCH 21/29] 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 --- .../enterprise_billing/billing_metrics.py | 20 +++++++++++---- .../test_billing_metrics.py | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/enterprise_billing/billing_metrics.py b/litellm/proxy/enterprise_billing/billing_metrics.py index e66a337f7a0c..89aac8b38e79 100644 --- a/litellm/proxy/enterprise_billing/billing_metrics.py +++ b/litellm/proxy/enterprise_billing/billing_metrics.py @@ -229,14 +229,24 @@ def load_billing_metrics_config( ) return None - required_paths = [paths.client_cert_path, paths.client_key_path] + ( - [paths.ca_cert_path] if paths.ca_cert_path else [] - ) - unreadable = [path for path in required_paths if not os.path.isfile(path)] + # Report the variable names, never their values. A value that is neither a + # readable path nor recognizable PEM is still secret material, and this + # warning would otherwise copy a client key straight into the proxy logs. + unreadable = [ + env_name + for env_name, path in ( + (CLIENT_CERT_ENV, paths.client_cert_path), + (CLIENT_KEY_ENV, paths.client_key_path), + (CA_CERT_ENV, paths.ca_cert_path), + ) + if path and not os.path.isfile(path) + ] if unreadable: verbose_proxy_logger.warning( - "Enterprise billing metrics disabled: certificate file(s) not found: %s", + "Enterprise billing metrics disabled: %s did not resolve to a readable certificate file. " + "Set each to a file path, or to inline PEM content beginning with '%s'.", ", ".join(unreadable), + _PEM_PREFIX, ) return None diff --git a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py index 3666440ae167..d7a564c6b7c4 100644 --- a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py +++ b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py @@ -240,6 +240,31 @@ def _explode(prefix=None): assert bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") is None +def test_load_config_never_logs_credential_values(monkeypatch): + """ + A value that is neither a readable path nor `-----BEGIN`-prefixed PEM is + still secret material. The disable warning must name the env vars, never + echo their contents, or a malformed key lands in the proxy logs. + """ + secret_material = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ-not-pem-prefixed" + monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") + monkeypatch.setenv(bm.CLIENT_CERT_ENV, secret_material) + monkeypatch.setenv(bm.CLIENT_KEY_ENV, secret_material) + + logged: List[str] = [] + + def _capture(msg, *args): + logged.append(msg % args if args else msg) + + monkeypatch.setattr(bm.verbose_proxy_logger, "warning", _capture) + + assert bm.load_billing_metrics_config(license_data=None, litellm_version="1.0") is None + + joined = "\n".join(logged) + assert secret_material not in joined + assert bm.CLIENT_CERT_ENV in joined and bm.CLIENT_KEY_ENV in joined + + def test_load_config_with_empty_pem_env_is_disabled(monkeypatch): """Empty stays empty: an unset secret must not be mistaken for inline PEM.""" monkeypatch.setenv(bm.ENDPOINT_ENV, "https://collector.example:4317") From baba77b5dc8de3e29a627c70bac26fee835f4921 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 20:57:39 +0300 Subject: [PATCH 22/29] Revert "fix(helm): truncate the helm.sh/chart label to 63 bytes" This reverts commit 4f7f706a63. Version hygiene belongs to the pipeline that mints chart versions, not to the chart. The build workflow now caps the version slug so litellm- fits the 63 byte label budget, which removes the overflow at the source rather than silently truncating a value operators use to identify the build. Drops the litellm.chart helper, restores the direct helm.sh/chart printf, and removes tests/chart_label_tests.yaml. Both chart suites stay green: helm unittest -f 'tests/*.yaml' helm/litellm # 20 passed helm unittest -f 'tests/*.yaml' helm/litellm-helm # 69 passed --- helm/litellm/templates/_helpers.tpl | 11 +---- helm/litellm/tests/chart_label_tests.yaml | 60 ----------------------- 2 files changed, 1 insertion(+), 70 deletions(-) delete mode 100644 helm/litellm/tests/chart_label_tests.yaml diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl index 80ab3d1e96ef..0d3a2551487f 100644 --- a/helm/litellm/templates/_helpers.tpl +++ b/helm/litellm/templates/_helpers.tpl @@ -27,20 +27,11 @@ Common naming + label helpers shared by gateway, backend, and ui templates. {{- printf "%s-ui" (include "litellm.fullname" .) | trunc 63 | trimSuffix "-" -}} {{- end -}} -{{/* -Chart label. Kubernetes caps a label value at 63 bytes, and .Chart.Version is -unbounded: CI branch builds version charts as 0.0.0-branch--, which -overflows and makes the API server reject every labeled resource. -*/}} -{{- define "litellm.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} -{{- end -}} - {{- define "litellm.commonLabels" -}} app.kubernetes.io/name: {{ include "litellm.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} -helm.sh/chart: {{ include "litellm.chart" . }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} {{- end -}} {{/* diff --git a/helm/litellm/tests/chart_label_tests.yaml b/helm/litellm/tests/chart_label_tests.yaml deleted file mode 100644 index 7bd0a4b9b4ea..000000000000 --- a/helm/litellm/tests/chart_label_tests.yaml +++ /dev/null @@ -1,60 +0,0 @@ -suite: test helm.sh/chart label stays within the 63 byte kubernetes limit -templates: - - gateway/deployment.yaml - - gateway/configmap.yaml - - backend/deployment.yaml - - ui/deployment.yaml - - migrations-job.yaml -values: - - ./values/required.yaml -tests: - - it: renders the plain chart label for a normal semver version - template: gateway/deployment.yaml - chart: - version: 0.1.0 - asserts: - - equal: - path: metadata.labels["helm.sh/chart"] - value: litellm-0.1.0 - - # CI publishes branch builds as 0.0.0-branch--. Untruncated, the - # label is 64 bytes and the API server rejects every labeled resource with - # "must be no more than 63 bytes", which wedges the whole release. - - it: truncates a long branch-build version to 63 bytes on the gateway - template: gateway/deployment.yaml - chart: - version: 0.0.0-branch-litellm-enterprise-request-metering-f0b217f - asserts: - - equal: - path: metadata.labels["helm.sh/chart"] - value: litellm-0.0.0-branch-litellm-enterprise-request-metering-f0b217 - - - it: truncates the long version on the migrations job that blocked the sync - template: migrations-job.yaml - chart: - version: 0.0.0-branch-litellm-enterprise-request-metering-f0b217f - asserts: - - equal: - path: metadata.labels["helm.sh/chart"] - value: litellm-0.0.0-branch-litellm-enterprise-request-metering-f0b217 - - - it: truncates the long version on backend and ui - templates: - - backend/deployment.yaml - - ui/deployment.yaml - chart: - version: 0.0.0-branch-litellm-enterprise-request-metering-f0b217f - asserts: - - equal: - path: metadata.labels["helm.sh/chart"] - value: litellm-0.0.0-branch-litellm-enterprise-request-metering-f0b217 - - # trunc can land on the separator; a label value may not end in a dash. - - it: never leaves a trailing dash after truncation - template: gateway/deployment.yaml - chart: - version: 0.0.0-branch-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbb - asserts: - - matchRegex: - path: metadata.labels["helm.sh/chart"] - pattern: "[^-]$" From 533b62c09f37c946e0126f53f2fbe234db7451b8 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 20:58:36 +0300 Subject: [PATCH 23/29] feat(helm): default billingMetrics.secretName to the conventional name The componentized chart required an explicit secretName while the classic chart defaults to litellm-billing-metrics-mtls. Both now default to it, so the common path is to create that Secret with tls.crt and tls.key and set enabled: true. The required() guard stays, and with a default it now only fires when someone explicitly blanks the override, which the tests pin from both sides --- helm/litellm/tests/billing_metrics_tests.yaml | 18 +++++++++++++++++- helm/litellm/values.yaml | 6 +++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/helm/litellm/tests/billing_metrics_tests.yaml b/helm/litellm/tests/billing_metrics_tests.yaml index c85fc9dfe42d..ceba0bd1430f 100644 --- a/helm/litellm/tests/billing_metrics_tests.yaml +++ b/helm/litellm/tests/billing_metrics_tests.yaml @@ -212,11 +212,27 @@ tests: - isNull: path: spec.template.spec.volumes - - it: fails loudly when enabled without a secretName + # 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 template: gateway/deployment.yaml set: billingMetrics: enabled: true + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: billing-metrics-mtls + secret: + secretName: litellm-billing-metrics-mtls + + - it: fails loudly when the secretName is explicitly blanked + template: gateway/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) diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml index b3b72c9530a4..da7c730ff2a8 100644 --- a/helm/litellm/values.yaml +++ b/helm/litellm/values.yaml @@ -82,7 +82,11 @@ masterKey: billingMetrics: enabled: false endpoint: https://telemetry.litellm.ai # collector to push the counter to - secretName: "" # existing Secret holding tls.crt and tls.key + # 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 From f80510e822cb0da1b38c1a6d891262188f3920c1 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 20:59:59 +0300 Subject: [PATCH 24/29] feat(proxy): log once when billing metrics are actually enabled build_billing_metrics_recorder returned None silently when the deployment was not licensed, while every other disable path logged a warning. An operator reading logs could not tell "metering active" from "metering off because this component never saw the license", and a component can carry the cert mount and the billing env and still meter nothing. That is the undercount direction the metric is not allowed to drift in. A successful build now emits one info line naming the collector endpoint and the export interval; neither the certificate contents nor the license appear. The unlicensed path logs at debug rather than warning, because unlicensed is the common case and a warning there would be noise on every OSS proxy --- .../enterprise_billing/billing_metrics.py | 11 +++++++ .../test_billing_metrics.py | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/litellm/proxy/enterprise_billing/billing_metrics.py b/litellm/proxy/enterprise_billing/billing_metrics.py index 89aac8b38e79..f9f8ceaf7216 100644 --- a/litellm/proxy/enterprise_billing/billing_metrics.py +++ b/litellm/proxy/enterprise_billing/billing_metrics.py @@ -286,6 +286,9 @@ def build_billing_metrics_recorder( ) -> Optional[BillingMetricsRecorder]: """Build the recorder, or None when the deployment is not licensed or metering is unconfigured.""" if not premium: + # Debug, not warning: unlicensed is the common case and a warning here + # would be noise on every OSS proxy. Every other disable path warns. + verbose_proxy_logger.debug("Enterprise billing metrics disabled: deployment is not licensed") return None config = load_billing_metrics_config(license_data=license_data, litellm_version=litellm_version) @@ -298,6 +301,14 @@ def build_billing_metrics_recorder( verbose_proxy_logger.warning("Enterprise billing metrics disabled: failed to initialize exporter: %s", exc) return None _ACTIVE_RECORDER.set(recorder) + # The only positive signal that this component meters. Without it, a silent + # return above is indistinguishable from a working exporter in the logs, and + # a component that carries the cert but no license would look healthy. + verbose_proxy_logger.info( + "Enterprise billing metrics enabled: exporting to %s every %d ms", + config.endpoint, + config.export_interval_ms, + ) return recorder diff --git a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py index d7a564c6b7c4..0446cfeeab03 100644 --- a/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py +++ b/tests/test_litellm/proxy/enterprise_billing/test_billing_metrics.py @@ -120,6 +120,39 @@ def _spy_getaddrinfo(host, port, *args, **kwargs): assert [host for host in resolved if "collector.example" in host] == [] +def test_building_the_recorder_logs_an_affirmative_line(monkeypatch, tmp_path): + """ + Every disable path logs; a successful build must log too. Otherwise an + operator cannot tell a metering component from one that silently returned + None, which is how an unlicensed component looks healthy while exporting + nothing. + """ + _set_full_env(monkeypatch, tmp_path) + monkeypatch.setenv(bm.EXPORT_INTERVAL_ENV, "5000") + monkeypatch.setattr(bm, "OTLPMetricExporter", _fake_exporter_class({})) + + infos: List[str] = [] + monkeypatch.setattr(bm.verbose_proxy_logger, "info", lambda msg, *args: infos.append(msg % args if args else msg)) + + recorder = bm.build_billing_metrics_recorder(premium=True, license_data={"user_id": "org-1"}, litellm_version="1.0") + + assert recorder is not None + joined = "\n".join(infos) + assert "https://collector.example:4317" in joined + assert "5000" in joined + + +def test_unlicensed_build_does_not_warn(monkeypatch, tmp_path): + """Unlicensed is the common OSS case; warning there would be pure noise.""" + _set_full_env(monkeypatch, tmp_path) + + warnings: List[str] = [] + monkeypatch.setattr(bm.verbose_proxy_logger, "warning", lambda msg, *args: warnings.append(str(msg))) + + assert bm.build_billing_metrics_recorder(premium=False, license_data=None, litellm_version="1.0") is None + assert warnings == [] + + def test_shutdown_flushes_active_recorder_once(monkeypatch, tmp_path): """The shutdown hook must flush the recorder the factory built (buffered counts are lost on restart otherwise) and be idempotent for repeat calls.""" From 726eb1d5ab87f8126452159f57dc7c002d82eae2 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 21:01:52 +0300 Subject: [PATCH 25/29] fix(terraform): fail the plan on a partial billing-metrics config Each PEM secret is created only when its own variable is non-empty, so setting billing_metrics_endpoint with a certificate but no key applied cleanly and left the proxy logging "missing config" and never exporting. Silent non-export is the undercount direction this metric must not drift in, and every other surface fails fast on a half-configured metering block. Both templates now carry a lifecycle precondition requiring the client certificate and its key together whenever the endpoint is set. It lives on the gateway task definition (aws) and the gateway Cloud Run service (gcp) rather than on the secret resources, because those are themselves count-gated on the PEM being present and would never evaluate in the failing case. Cross-variable `validation` blocks would need terraform 1.9; versions.tf pins >= 1.6, and preconditions work there. ca_cert_pem stays optional, so an empty value still falls back to the system trust store. endpoint cert key result "" any any metering off, no secrets created set set set metering on set missing either plan fails Verified each row with `terraform console` against the condition, and reran `terraform fmt -check` and `terraform validate` in both directories --- terraform/litellm/aws/ecs.tf | 19 +++++++++++++++++++ terraform/litellm/gcp/cloudrun.tf | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/terraform/litellm/aws/ecs.tf b/terraform/litellm/aws/ecs.tf index bf0487c0a93f..e6848421779b 100644 --- a/terraform/litellm/aws/ecs.tf +++ b/terraform/litellm/aws/ecs.tf @@ -207,6 +207,25 @@ locals { # ---------- Gateway ---------- resource "aws_ecs_task_definition" "gateway" { + # Metering needs a client certificate AND its key. Each secret is created only + # when its own PEM is supplied, so an endpoint set with a missing key would + # otherwise apply cleanly and leave the proxy logging "missing config" and + # never exporting. ca_cert_pem stays optional: empty means fall back to the + # system trust store. + # + # endpoint cert key -> result + # "" any any -> metering off, no secrets created + # set set set -> metering on + # set any-missing -> plan fails here + lifecycle { + precondition { + condition = var.billing_metrics_endpoint == "" || ( + var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" + ) + error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set." + } + } + family = "${local.name}-gateway" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index 913ec2bbb3d8..5b47f1e08f48 100644 --- a/terraform/litellm/gcp/cloudrun.tf +++ b/terraform/litellm/gcp/cloudrun.tf @@ -165,6 +165,25 @@ locals { # ---------- Gateway ---------- resource "google_cloud_run_v2_service" "gateway" { + # Metering needs a client certificate AND its key. Each secret is created only + # when its own PEM is supplied, so an endpoint set with a missing key would + # otherwise apply cleanly and leave the proxy logging "missing config" and + # never exporting. ca_cert_pem stays optional: empty means fall back to the + # system trust store. + # + # endpoint cert key -> result + # "" any any -> metering off, no secrets created + # set set set -> metering on + # set any-missing -> plan fails here + lifecycle { + precondition { + condition = var.billing_metrics_endpoint == "" || ( + var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" + ) + error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set." + } + } + name = "${local.name}-gateway" location = var.region ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" From 5933294f10f63f70fe2bccdaacfcf98ef5830442 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 21:10:33 +0300 Subject: [PATCH 26/29] docs(terraform): record why the billing guard sits on the gateway resource The precondition cannot live on the cert secret, which is count-gated on the cert itself and so has zero instances in exactly the case the guard must catch. That makes the guard's correctness depend on this resource staying unconditional, which nothing else records and no test enforces --- terraform/litellm/aws/ecs.tf | 5 +++++ terraform/litellm/gcp/cloudrun.tf | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/terraform/litellm/aws/ecs.tf b/terraform/litellm/aws/ecs.tf index e6848421779b..0fe5fe3762a8 100644 --- a/terraform/litellm/aws/ecs.tf +++ b/terraform/litellm/aws/ecs.tf @@ -213,6 +213,11 @@ resource "aws_ecs_task_definition" "gateway" { # never exporting. ca_cert_pem stays optional: empty means fall back to the # system trust store. # + # The guard lives here, on an unconditional resource, rather than on the cert + # secret: that secret is count-gated on the cert itself, so it has zero + # instances in exactly the case this must catch. Adding count or for_each to + # this resource would silently stop the guard from evaluating. + # # endpoint cert key -> result # "" any any -> metering off, no secrets created # set set set -> metering on diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index 5b47f1e08f48..fa66d71f0775 100644 --- a/terraform/litellm/gcp/cloudrun.tf +++ b/terraform/litellm/gcp/cloudrun.tf @@ -171,6 +171,11 @@ resource "google_cloud_run_v2_service" "gateway" { # never exporting. ca_cert_pem stays optional: empty means fall back to the # system trust store. # + # The guard lives here, on an unconditional resource, rather than on the cert + # secret: that secret is count-gated on the cert itself, so it has zero + # instances in exactly the case this must catch. Adding count or for_each to + # this resource would silently stop the guard from evaluating. + # # endpoint cert key -> result # "" any any -> metering off, no secrets created # set set set -> metering on From 534ddfdba2e608e6977ea3d18179e618babb8961 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 21:29:41 +0300 Subject: [PATCH 27/29] fix(terraform): guard the backend against a partial billing config too The precondition only sat on the gateway, but the backend receives the billing endpoint as well, because it serves the named-server MCP transport and meters it. A targeted apply of just the backend task or service would therefore skip the guard entirely and provision a component holding a billing endpoint with no credentials to use it, which is the silent never-export failure the guard exists to prevent. Both templates now carry the same precondition on the backend resource. The condition and truth table are unchanged; ca_cert_pem stays optional. terraform fmt -check and terraform validate clean in both directories --- terraform/litellm/aws/ecs.tf | 12 ++++++++++++ terraform/litellm/gcp/cloudrun.tf | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/terraform/litellm/aws/ecs.tf b/terraform/litellm/aws/ecs.tf index 0fe5fe3762a8..4df41c278e88 100644 --- a/terraform/litellm/aws/ecs.tf +++ b/terraform/litellm/aws/ecs.tf @@ -317,6 +317,18 @@ resource "aws_ecs_service" "gateway" { # ---------- Backend ---------- resource "aws_ecs_task_definition" "backend" { + # Same guard as the gateway: the backend meters too (it serves the named-server + # MCP transport), and a targeted apply of just this resource must not slip a + # billing endpoint through without the credentials to use it. + lifecycle { + precondition { + condition = var.billing_metrics_endpoint == "" || ( + var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" + ) + error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set." + } + } + family = "${local.name}-backend" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index fa66d71f0775..57533b717319 100644 --- a/terraform/litellm/gcp/cloudrun.tf +++ b/terraform/litellm/gcp/cloudrun.tf @@ -306,6 +306,18 @@ resource "google_cloud_run_v2_service" "gateway" { # ---------- Backend ---------- resource "google_cloud_run_v2_service" "backend" { + # Same guard as the gateway: the backend meters too (it serves the named-server + # MCP transport), and a targeted apply of just this resource must not slip a + # billing endpoint through without the credentials to use it. + lifecycle { + precondition { + condition = var.billing_metrics_endpoint == "" || ( + var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" + ) + error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set." + } + } + name = "${local.name}-backend" location = var.region ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" From c6bb384dc50c05de82ce47b9e4147d13e61b7987 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 15 Jul 2026 11:13:58 -0700 Subject: [PATCH 28/29] docs(team): document mcp_rpm_limit in update_team docstring --- litellm/proxy/management_endpoints/team_endpoints.py | 1 + 1 file changed, 1 insertion(+) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 797f46008576..d267a3cac691 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -1621,6 +1621,7 @@ async def update_team( - allowed_passthrough_routes: Optional[List[str]] - List of allowed pass through routes for the team. - model_rpm_limit: Optional[Dict[str, int]] - The RPM (Requests Per Minute) limit per model for this team. Example: {"gpt-4": 100, "gpt-3.5-turbo": 200} - model_tpm_limit: Optional[Dict[str, int]] - The TPM (Tokens Per Minute) limit per model for this team. Example: {"gpt-4": 10000, "gpt-3.5-turbo": 20000} + - mcp_rpm_limit: Optional[Dict[str, int]] - Per-MCP-server RPM limit for this team, keyed by MCP server name (alias if set, else the configured name). Example: {"github": 100, "slack": 200}. Applied across all keys for this team. Example - update team TPM Limit - allowed_vector_store_indexes: Optional[List[dict]] - List of allowed vector store indexes for the key. Example - [{"index_name": "my-index", "index_permissions": ["write", "read"]}]. If specified, the key will only be able to use these specific vector store indexes. Create index, using `/v1/indexes` endpoint. - secret_manager_settings: Optional[dict] - Secret manager settings for the team. [Docs](https://docs.litellm.ai/docs/secret_managers/overview) From 6d0a044aece58ff775fd7d038ebadbf29e7ef869 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 15 Jul 2026 11:32:59 -0700 Subject: [PATCH 29/29] chore(ui): regenerate schema.d.ts for update_team docstring change --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 80e9b41852f7..9f6d7410e77d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -13810,6 +13810,7 @@ export interface paths { * - allowed_passthrough_routes: Optional[List[str]] - List of allowed pass through routes for the team. * - model_rpm_limit: Optional[Dict[str, int]] - The RPM (Requests Per Minute) limit per model for this team. Example: {"gpt-4": 100, "gpt-3.5-turbo": 200} * - model_tpm_limit: Optional[Dict[str, int]] - The TPM (Tokens Per Minute) limit per model for this team. Example: {"gpt-4": 10000, "gpt-3.5-turbo": 20000} + * - mcp_rpm_limit: Optional[Dict[str, int]] - Per-MCP-server RPM limit for this team, keyed by MCP server name (alias if set, else the configured name). Example: {"github": 100, "slack": 200}. Applied across all keys for this team. * Example - update team TPM Limit * - allowed_vector_store_indexes: Optional[List[dict]] - List of allowed vector store indexes for the key. Example - [{"index_name": "my-index", "index_permissions": ["write", "read"]}]. If specified, the key will only be able to use these specific vector store indexes. Create index, using `/v1/indexes` endpoint. * - secret_manager_settings: Optional[dict] - Secret manager settings for the team. [Docs](https://docs.litellm.ai/docs/secret_managers/overview)