fix(proxy): let a config license unlock enterprise settings and spell "environment" right in license errors (#49745)

* fix(proxy): spell "environment" right in the allowed_ips and AWS KMS V2 license errors

* fix(proxy): read general_settings.litellm_license before the allowed_ips and enforced_params gates

* fix(proxy): read the config license before callbacks and gates, keeping a valid env license

* fix(proxy): keep an invalid config license reported and sync license data with the license in use

A config license that fails no longer overwrites the license string when the env license is not valid either, so /health/license still reports has_license. A failed boot-time env license is not re-checked. Both license writes go through one helper that also refreshes premium_user_data, so /health/license and the seat limits follow the license in use.

* fix(proxy): keep the env license's boot result when a config license fails, without a second license check

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
paveln83 2026-11-03 01:56:08 -08:00 • committed by GitHub
parent 28b4300be2
commit 7bd342d7ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 261 additions and 11 deletions

View file

@ -1185,6 +1185,27 @@ server_root_path: Final = get_server_root_path()
_license_check = LicenseCheck()
premium_user: bool = _license_check.is_premium()
premium_user_data: Optional["EnterpriseLicenseData"] = _license_check.airgapped_license_data
def _set_premium_user(is_premium: bool) -> None:
global premium_user, premium_user_data
premium_user = is_premium # rebind-ok: config load can switch the license in use
premium_user_data = _license_check.airgapped_license_data # rebind-ok: tracks the license in use
def _use_config_license(config_license: str, env_license_is_premium: bool) -> bool:
env_license: Final = _license_check.license_str
env_license_data: Final = _license_check.airgapped_license_data
_license_check.license_str = config_license
if _license_check.is_premium():
return True
if not env_license_is_premium:
return False
_license_check.license_str = env_license
_license_check.airgapped_license_data = env_license_data
return True
global_max_parallel_request_retries_env: Final[str | None] = os.getenv("LITELLM_GLOBAL_MAX_PARALLEL_REQUEST_RETRIES")
proxy_state: Final = ProxyState()
SENSITIVE_DATA_MASKER: Final = SensitiveDataMasker()
@ -6686,7 +6707,6 @@ class ProxyConfig:
def _load_environment_variables(self, config: dict):
## ENVIRONMENT VARIABLES
global premium_user
environment_variables: Final = config.get("environment_variables", None)
if environment_variables:
for key, value in environment_variables.items():
@ -6726,7 +6746,7 @@ class ProxyConfig:
# check if litellm_license in general_settings
if "LITELLM_LICENSE" in environment_variables:
_license_check.license_str = os.getenv("LITELLM_LICENSE", None)
premium_user = _license_check.is_premium()
_set_premium_user(_license_check.is_premium())
def _set_config_environment_variable(self, key: str, value: str) -> None:
if key in os.environ and os.environ[key] != value:
@ -6778,7 +6798,6 @@ class ProxyConfig:
prompt_injection_detection_obj, \
redis_usage_cache, \
store_model_in_db, \
premium_user, \
open_telemetry_logger, \
health_check_details, \
proxy_batch_polling_interval, \
@ -6791,6 +6810,12 @@ class ProxyConfig:
self._load_environment_variables(config=config)
config_license: Final = _GENERAL_SETTINGS_VIEW.validate_python(
_GENERAL_SETTINGS_VIEW.validate_python(config).get("general_settings") or {}
).get("litellm_license")
if isinstance(config_license, str):
_set_premium_user(_use_config_license(config_license, env_license_is_premium=premium_user))
## Coordination Redis (before cache init, so the explicit block wins)
coordination_redis_cache: Final = self._init_coordination_redis(config=config)
if coordination_redis_cache is not None:
@ -7367,7 +7392,7 @@ class ProxyConfig:
allowed_ips: Final = general_settings.get("allowed_ips", None)
if allowed_ips is not None and premium_user is False:
raise ValueError(
"allowed_ips is an Enterprise Feature. Please add a valid LITELLM_LICENSE to your envionment."
"allowed_ips is an Enterprise Feature. Please add a valid LITELLM_LICENSE to your environment."
)
## BUDGET RESCHEDULER ##
proxy_budget_rescheduler_min_time = general_settings.get(
@ -7423,11 +7448,6 @@ class ProxyConfig:
if general_settings.get("enforced_params") is not None and premium_user is not True:
raise ValueError("Trying to use `enforced_params`" + CommonProxyErrors.not_premium_user.value)
# check if litellm_license in general_settings
if "litellm_license" in general_settings:
_license_check.license_str = general_settings["litellm_license"]
premium_user = _license_check.is_premium()
router_params: Final[dict] = {
"cache_responses": litellm.cache is not None, # cache if user passed in cache values
}

View file

@ -70,7 +70,7 @@ class AWSKeyManagementService_V2:
is_litellm_license_in_env = True
if is_litellm_license_in_env is False:
raise ValueError(
"AWSKeyManagementService V2 is an Enterprise Feature. Please add a valid LITELLM_LICENSE to your envionment."
"AWSKeyManagementService V2 is an Enterprise Feature. Please add a valid LITELLM_LICENSE to your environment."
)
def load_aws_kms(self, use_aws_kms: bool | None):

View file

@ -9,6 +9,7 @@ Pins covered:
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
@ -25,11 +26,15 @@ from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from prisma.errors import DataError
from pydantic import JsonValue, TypeAdapter, ValidationError
import litellm
from litellm._logging import turn_on_json
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.proxy._types import CommonProxyErrors, ConfigGeneralSettings
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
from litellm.proxy.config_resolvers.settings_store import SettingsStore
@ -2671,6 +2676,213 @@ async def test_load_config_logs_disabled_budget_reservation_once(tmp_path, monke
assert [record.levelno for record in records] == ([logging.INFO] if setting == "true" else [])
@pytest.mark.asyncio
async def test_load_config_refuses_allowed_ips_without_a_license(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
config_file: Final = tmp_path / "allowed_ips.yaml"
config_file.write_text('model_list: []\nlitellm_settings: {}\ngeneral_settings:\n allowed_ips: ["127.0.0.1"]\n')
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False)
monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False)
expected_message: Final = (
"allowed_ips is an Enterprise Feature. Please add a valid LITELLM_LICENSE to your environment."
)
with pytest.raises(ValueError, match=f"^{re.escape(expected_message)}$"):
await ProxyConfig().load_config(router=None, config_file_path=str(config_file))
def _signed_test_license(private_key: RSAPrivateKey, max_users: int) -> str:
message: Final = json.dumps({"expiration_date": "9999-12-31", "user_id": "test", "max_users": max_users}).encode()
signature: Final = private_key.sign(
message,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256(),
)
return base64.b64encode(message + b"." + signature).decode()
def _write_config(path: Path, general_settings: dict[str, object], litellm_settings: dict[str, object]) -> str:
path.write_text(
json.dumps({"model_list": [], "litellm_settings": litellm_settings, "general_settings": general_settings})
)
return str(path)
def _boot_proxy(
monkeypatch: pytest.MonkeyPatch,
public_key: RSAPublicKey,
env_license: str | None,
license_server_urls: list[str],
server_accepted_license: str | None = None,
) -> None:
from litellm.proxy import proxy_server
def _license_server(request: httpx.Request) -> httpx.Response:
license_server_urls.append(str(request.url))
return httpx.Response(
200, json={"verify": server_accepted_license is not None and server_accepted_license in str(request.url)}
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False)
monkeypatch.setattr("litellm.proxy.proxy_server._license_check.public_key", public_key)
monkeypatch.setattr("litellm.proxy.proxy_server._license_check.airgapped_license_data", None)
monkeypatch.setattr(
"litellm.proxy.proxy_server._license_check.http_handler",
HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_license_server))),
)
monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False)
if env_license is None:
monkeypatch.delenv("LITELLM_LICENSE", raising=False)
else:
monkeypatch.setenv("LITELLM_LICENSE", env_license)
monkeypatch.setattr("litellm.proxy.proxy_server._license_check.license_str", env_license)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", proxy_server._license_check.is_premium())
monkeypatch.setattr(
"litellm.proxy.proxy_server.premium_user_data", proxy_server._license_check.airgapped_license_data
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"gated_config",
[
{"general_settings": {"allowed_ips": ["127.0.0.1"]}},
{"general_settings": {"enforced_params": ["user"]}},
{"litellm_settings": {"callbacks": ["openai_moderations"]}},
],
)
async def test_load_config_lets_a_config_license_unlock_its_enterprise_gates(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gated_config: dict[str, dict[str, list[str]]]
):
from litellm.proxy import proxy_server
private_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
license_key: Final = _signed_test_license(private_key, max_users=10)
config_file: Final = _write_config(
tmp_path / "licensed.yaml",
{"litellm_license": license_key, **gated_config.get("general_settings", {})},
dict(gated_config.get("litellm_settings", {})),
)
_boot_proxy(monkeypatch, private_key.public_key(), env_license=None, license_server_urls=[])
await ProxyConfig().load_config(router=None, config_file_path=config_file)
assert proxy_server.premium_user is True
@pytest.mark.asyncio
async def test_load_config_keeps_a_valid_env_license_when_the_config_license_is_invalid(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from litellm.proxy import proxy_server
private_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
config_file: Final = _write_config(
tmp_path / "stale_license.yaml", {"litellm_license": "not-a-valid-license", "allowed_ips": ["127.0.0.1"]}, {}
)
_boot_proxy(
monkeypatch,
private_key.public_key(),
env_license=_signed_test_license(private_key, max_users=5),
license_server_urls=[],
)
await ProxyConfig().load_config(router=None, config_file_path=config_file)
assert proxy_server.premium_user is True
assert proxy_server._license_check.user_seat_limit() == 5
@pytest.mark.asyncio
async def test_load_config_prefers_the_config_license_when_both_licenses_are_valid(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from litellm.proxy import proxy_server
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.health_endpoints._health_endpoints import health_license_endpoint
private_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
config_file: Final = _write_config(
tmp_path / "licensed.yaml", {"litellm_license": _signed_test_license(private_key, max_users=100)}, {}
)
_boot_proxy(
monkeypatch,
private_key.public_key(),
env_license=_signed_test_license(private_key, max_users=5),
license_server_urls=[],
)
await ProxyConfig().load_config(router=None, config_file_path=config_file)
assert proxy_server._license_check.user_seat_limit() == 100
assert (await health_license_endpoint(user_api_key_dict=UserAPIKeyAuth()))["limits"]["max_users"] == 100
@pytest.mark.asyncio
async def test_load_config_still_reports_an_invalid_config_license_as_configured(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.health_endpoints._health_endpoints import health_license_endpoint
private_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
config_file: Final = _write_config(tmp_path / "stale_license.yaml", {"litellm_license": "not-a-valid-license"}, {})
_boot_proxy(monkeypatch, private_key.public_key(), env_license=None, license_server_urls=[])
await ProxyConfig().load_config(router=None, config_file_path=config_file)
license_status: Final = await health_license_endpoint(user_api_key_dict=UserAPIKeyAuth())
assert (license_status["has_license"], license_status["license_type"]) == (True, "community")
@pytest.mark.asyncio
async def test_load_config_does_not_recheck_an_env_license_that_failed_at_boot(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from litellm.proxy import proxy_server
private_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
config_file: Final = _write_config(tmp_path / "stale_license.yaml", {"litellm_license": "not-a-valid-license"}, {})
license_server_urls: Final[list[str]] = []
_boot_proxy(monkeypatch, private_key.public_key(), "also-not-a-valid-license", license_server_urls)
asked_at_boot: Final = len(license_server_urls)
await ProxyConfig().load_config(router=None, config_file_path=config_file)
assert proxy_server.premium_user is False
assert [url for url in license_server_urls[asked_at_boot:] if "also-not-a-valid-license" in url] == []
assert any("not-a-valid-license" in url for url in license_server_urls[asked_at_boot:])
@pytest.mark.asyncio
async def test_load_config_keeps_the_boot_verdict_of_a_server_checked_env_license(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from litellm.proxy import proxy_server
private_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
config_file: Final = _write_config(
tmp_path / "stale_license.yaml", {"litellm_license": "not-a-valid-license", "allowed_ips": ["127.0.0.1"]}, {}
)
license_server_urls: Final[list[str]] = []
_boot_proxy(
monkeypatch,
private_key.public_key(),
"server-checked-license",
license_server_urls,
server_accepted_license="server-checked-license",
)
asked_at_boot: Final = len(license_server_urls)
await ProxyConfig().load_config(router=None, config_file_path=config_file)
assert proxy_server.premium_user is True
assert proxy_server._license_check.license_str == "server-checked-license"
assert [url for url in license_server_urls[asked_at_boot:] if "server-checked-license" in url] == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"setting", ["vector_store_deny_by_default", "search_tool_deny_by_default", "claude_code_marketplace_auth_required"]

View file

@ -4117,7 +4117,11 @@ async def test_load_environment_variables_litellm_license_and_edge_cases():
mock_license_check = MagicMock()
mock_license_check.is_premium.return_value = True
with patch("litellm.proxy.proxy_server._license_check", mock_license_check):
with (
patch("litellm.proxy.proxy_server._license_check", mock_license_check),
patch("litellm.proxy.proxy_server.premium_user", False),
patch("litellm.proxy.proxy_server.premium_user_data", None),
):
with patch.dict(os.environ, {}, clear=False):
# Call the method under test
proxy_config._load_environment_variables(test_config_with_license)

View file

@ -1,4 +1,6 @@
import base64
import re
from typing import Final
import pytest
from botocore.stub import Stubber
@ -34,3 +36,15 @@ def test_decrypt_value_turns_only_boolean_literals_into_bools(monkeypatch, plain
assert decrypted == expected
assert type(decrypted) is type(expected)
def test_kms_v2_without_a_license_tells_the_user_to_set_one(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("AWS_REGION_NAME", "us-west-2")
monkeypatch.delenv("LITELLM_LICENSE", raising=False)
monkeypatch.delenv("LITELLM_SECRET_AWS_KMS_LITELLM_LICENSE", raising=False)
expected_message: Final = (
"AWSKeyManagementService V2 is an Enterprise Feature. Please add a valid LITELLM_LICENSE to your environment."
)
with pytest.raises(ValueError, match=f"^{re.escape(expected_message)}$"):
AWSKeyManagementService_V2()