From 1b91dd4c0bfcacec9fbadfae1073593ce2c91000 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 19:41:30 +0300 Subject: [PATCH] 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 4b2a7a22232..e66a337f7a0 100644 --- a/litellm/proxy/enterprise_billing/billing_metrics.py +++ b/litellm/proxy/enterprise_billing/billing_metrics.py @@ -14,6 +14,7 @@ payload; the secret license key is never sent as an attribute or header. """ import os +import tempfile from dataclasses import dataclass from typing import TYPE_CHECKING, Optional, Union @@ -40,6 +41,15 @@ DEFAULT_EXPORT_INTERVAL_MS = 60_000 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 4d77ce7c2ae..3666440ae16 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 @@ collectors), the metric attribute mapping, and that recording produces the 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")