Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b8e367d
πŸ› fix(proxy_server): remove redundant decryption of already-decrypted…
danielaskdd Mar 25, 2026
9ef938a
βœ… test(proxy_unit_tests): update decrypt_value_helper mock in callbac…
danielaskdd Mar 25, 2026
b8adffc
✨ feat(proxy): decrypt env vars in get_config for both DB and YAML modes
danielaskdd Mar 26, 2026
687b1c1
πŸ› fix(health_endpoints): resolve TEST_EMAIL_ADDRESS not read from DB …
danielaskdd Mar 26, 2026
631942d
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 1, 2026
aa19b74
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 1, 2026
8d4b8ce
✨ feat(health): add database-backed test email resolution for email h…
danielaskdd Apr 1, 2026
277e398
♻️ refactor(health_endpoints): eliminate proxy startup import cycles
danielaskdd Apr 1, 2026
e2a3ccc
♻️ refactor(health_endpoints): eliminate proxy import cycles in healt…
danielaskdd Apr 1, 2026
d341828
♻️ refactor(health_endpoints): extract encryption utilities and updat…
danielaskdd Apr 1, 2026
c87f7c8
♻️ refactor(encrypt_decrypt): extract signing key logic to dedicated …
danielaskdd Apr 1, 2026
659c6aa
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 1, 2026
38d9e4c
βœ… test(health_endpoints): add comprehensive unit tests for helper fun…
danielaskdd Apr 2, 2026
870a789
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 3, 2026
a8b853d
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 5, 2026
33bf45d
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 9, 2026
0f7c9f4
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 9, 2026
5e9ca6d
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 10, 2026
3cf35a6
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 10, 2026
ff5c330
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 10, 2026
29fb312
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 11, 2026
b1af449
Merge branch 'main' into fix/redundant-decrption
danielaskdd Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 123 additions & 2 deletions litellm/proxy/health_endpoints/_health_endpoints.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import copy
import json
import logging
import os
import time
Expand Down Expand Up @@ -36,11 +37,47 @@
from litellm.proxy.middleware.in_flight_requests_middleware import (
get_in_flight_requests,
)
from litellm.secret_managers.main import get_secret

#### Health ENDPOINTS ####


def get_secret(
secret_name: str, default_value: Optional[Union[str, bool]] = None
) -> Optional[Union[str, bool]]:
# Import lazily to avoid proxy startup cycles involving secret manager setup.
from litellm.secret_managers.main import get_secret as _get_secret

return _get_secret(secret_name=secret_name, default_value=default_value)


def get_secret_bool(
secret_name: str, default_value: Optional[bool] = None
) -> Optional[bool]:
# Import lazily to avoid proxy startup cycles involving secret manager setup.
from litellm.secret_managers.main import get_secret_bool as _get_secret_bool

return _get_secret_bool(secret_name=secret_name, default_value=default_value)


def decrypt_value_helper(
value: str,
key: str,
exception_type: Literal["debug", "error"] = "error",
return_original_value: bool = False,
) -> Optional[str]:
# Import lazily so this module does not participate in proxy import cycles.
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper as _decrypt_value_helper,
)

return _decrypt_value_helper(
value=value,
key=key,
exception_type=exception_type,
return_original_value=return_original_value,
)


def _resolve_os_environ_variables(params: dict) -> dict:
"""
Resolve ``os.environ/`` environment variables in ``litellm_params``.
Expand Down Expand Up @@ -145,6 +182,90 @@
return callback_name(callback)


def _parse_config_row_param_value(param_value: Any) -> dict:
if param_value is None:
return {}

if isinstance(param_value, str):
try:
parsed_value = json.loads(param_value)
except json.JSONDecodeError:
return {}
return parsed_value if isinstance(parsed_value, dict) else {}

if isinstance(param_value, dict):
return dict(param_value)

try:
parsed_value = dict(param_value)
except (TypeError, ValueError):
return {}

return parsed_value if isinstance(parsed_value, dict) else {}


def _is_truthy_config_flag(value: Any) -> bool:
if isinstance(value, bool):
return value

if isinstance(value, str):
return value.strip().lower() == "true"

if value is None:
return False

return bool(value)


async def _resolve_test_email_address(prisma_client: Any) -> Optional[str]:
test_email_address = os.getenv("TEST_EMAIL_ADDRESS")

try:
store_model_in_db = (
get_secret_bool("STORE_MODEL_IN_DB", default_value=False) is True
)

if not store_model_in_db and prisma_client is not None:
general_settings_row = await prisma_client.db.litellm_config.find_unique(
where={"param_name": "general_settings"}
)
general_settings = _parse_config_row_param_value(
getattr(general_settings_row, "param_value", None)
)
store_model_in_db = _is_truthy_config_flag(
general_settings.get("store_model_in_db")
)

if not store_model_in_db or prisma_client is None:
return test_email_address

environment_variables_row = await prisma_client.db.litellm_config.find_unique(
where={"param_name": "environment_variables"}
)
environment_variables = _parse_config_row_param_value(
getattr(environment_variables_row, "param_value", None)
)
db_test_email_address = environment_variables.get("TEST_EMAIL_ADDRESS")

if db_test_email_address is None:
return test_email_address

decrypted_test_email_address = decrypt_value_helper(
value=db_test_email_address,
key="TEST_EMAIL_ADDRESS",
exception_type="debug",
return_original_value=True,
)

return decrypted_test_email_address or test_email_address
except Exception as e:
verbose_proxy_logger.debug(
"Falling back to TEST_EMAIL_ADDRESS from env after DB lookup failed: %s",
str(e),
)
return test_email_address


router = APIRouter()
services = Union[
Literal[
Expand Down Expand Up @@ -447,7 +568,7 @@
spend=0,
max_budget=0,
user_id=user_api_key_dict.user_id,
user_email=os.getenv("TEST_EMAIL_ADDRESS"),
user_email=await _resolve_test_email_address(prisma_client),
team_id=user_api_key_dict.team_id,
)

Expand Down
19 changes: 13 additions & 6 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12929,11 +12929,11 @@ def normalize_callback(callback):
_value = os.getenv("SLACK_WEBHOOK_URL", None)
_slack_env_vars[_var] = _value
else:
# decode + decrypt the value
_decrypted_value = decrypt_value_helper(
value=env_variable, key=_var
_slack_env_vars[_var] = decrypt_value_helper(
value=env_variable,
key=_var,
return_original_value=True,
)
_slack_env_vars[_var] = _decrypted_value

_alerting_types = proxy_logging_obj.slack_alerting_instance.alert_types
_all_alert_types = (
Expand Down Expand Up @@ -12967,8 +12967,15 @@ def normalize_callback(callback):
if env_variable is None:
_email_env_vars[_var] = None
else:
# decode + decrypt the value
_decrypted_value = decrypt_value_helper(value=env_variable, key=_var)
# Use return_original_value=True so this works for both:
# - DB mode: values already decrypted by _update_config_from_db β†’ decryption
# fails gracefully and returns the original plaintext value
# - YAML mode: values still encrypted in config file β†’ decrypted here
_decrypted_value = decrypt_value_helper(
value=env_variable,
key=_var,
return_original_value=True,
)
_email_env_vars[_var] = _decrypted_value

alerting_data.append(
Expand Down
67 changes: 67 additions & 0 deletions tests/proxy_unit_tests/test_proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2743,6 +2743,73 @@ async def test_get_config_callbacks_environment_variables(client_no_auth):
assert otel_vars["OTEL_HEADERS"] == "key=value"


@pytest.mark.asyncio
async def test_get_config_callbacks_email_and_slack_values_are_not_decrypted_again(
client_no_auth,
):
"""
Test that /get/config/callbacks returns already-decrypted email/slack values as-is.

decrypt_value_helper is called with return_original_value=True, so for already-plaintext
values (DB mode: decrypted by _update_config_from_db) it returns the original value
unchanged. For encrypted values (YAML mode) it properly decrypts them.
"""
mock_config_data = {
"litellm_settings": {},
"environment_variables": {
"SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/test/webhook",
"SMTP_HOST": "10.16.68.20",
"SMTP_PORT": "587",
"SMTP_USERNAME": "smtp-user",
"SMTP_PASSWORD": "smtp-password",
"SMTP_SENDER_EMAIL": "alerts@example.com",
"TEST_EMAIL_ADDRESS": "ops@example.com",
"EMAIL_LOGO_URL": "https://example.com/logo.png",
"EMAIL_SUPPORT_CONTACT": "support@example.com",
},
"general_settings": {"alerting": ["slack"]},
}

proxy_config = getattr(litellm.proxy.proxy_server, "proxy_config")

# Simulate return_original_value=True behaviour: return the value as-is (already plaintext)
def fake_decrypt(value, key, return_original_value=False, **kwargs):
return value

with patch.object(
proxy_config, "get_config", new=AsyncMock(return_value=mock_config_data)
), patch(
"litellm.proxy.proxy_server.decrypt_value_helper",
side_effect=fake_decrypt,
) as decrypt_mock:
response = client_no_auth.get("/get/config/callbacks")

assert response.status_code == 200
result = response.json()
alerts = result["alerts"]

slack_alert = next((alert for alert in alerts if alert["name"] == "slack"), None)
assert slack_alert is not None
assert slack_alert["variables"] == {
"SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/test/webhook"
}

email_alert = next((alert for alert in alerts if alert["name"] == "email"), None)
assert email_alert is not None
assert email_alert["variables"] == {
"SMTP_HOST": "10.16.68.20",
"SMTP_PORT": "587",
"SMTP_USERNAME": "smtp-user",
"SMTP_PASSWORD": "smtp-password",
"SMTP_SENDER_EMAIL": "alerts@example.com",
"TEST_EMAIL_ADDRESS": "ops@example.com",
"EMAIL_LOGO_URL": "https://example.com/logo.png",
"EMAIL_SUPPORT_CONTACT": "support@example.com",
}
# decrypt_value_helper is called once per SMTP var + once for SLACK_WEBHOOK_URL
assert decrypt_mock.call_count == len(mock_config_data["environment_variables"])


@pytest.mark.asyncio
async def test_update_config_success_callback_normalization():
"""
Expand Down
Loading
Loading