Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
18 changes: 17 additions & 1 deletion litellm/proxy/health_endpoints/_health_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,22 @@
},
)
if service == "email":
from litellm.proxy.proxy_server import proxy_config, store_model_in_db

# TEST_EMAIL_ADDRESS is stored encrypted in the DB when the proxy
# is running in DB mode. Calling get_config() ensures the value is
# freshly decrypted and available both in the returned config dict
# and in os.environ. For YAML / env-var deployments the call is a
# cheap no-op and os.getenv() still works as the fallback.
if store_model_in_db and prisma_client is not None:
_fresh_config = await proxy_config.get_config()
_env_vars = _fresh_config.get("environment_variables", {})
_test_email_address = _env_vars.get(
"TEST_EMAIL_ADDRESS"
) or os.getenv("TEST_EMAIL_ADDRESS")
else:
_test_email_address = os.getenv("TEST_EMAIL_ADDRESS")

webhook_event = WebhookEvent(
event="key_created",
event_group=Litellm_EntityType.KEY,
Expand All @@ -447,7 +463,7 @@
spend=0,
max_budget=0,
user_id=user_api_key_dict.user_id,
user_email=os.getenv("TEST_EMAIL_ADDRESS"),
user_email=_test_email_address,
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
18 changes: 15 additions & 3 deletions ui/litellm-dashboard/src/components/email_settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface EmailSettingsProps {
}

const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser, alerts }) => {
const handleSaveEmailSettings = async () => {
const handleSaveEmailSettings = async ({ silent = false }: { silent?: boolean } = {}) => {
if (!accessToken) {
return;
}
Expand Down Expand Up @@ -43,9 +43,15 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
};
try {
await setCallbacksCall(accessToken, payload);
NotificationManager.success("Email settings updated successfully");
if (!silent) {
NotificationManager.success("Email settings updated successfully");
}
} catch (error) {
NotificationManager.fromBackend(error);
if (!silent) {
NotificationManager.fromBackend(error);
}
// In silent mode (called from test flow) swallow the error so that
// the test can still proceed using env-var / YAML config values.
}
};

Expand Down Expand Up @@ -163,6 +169,12 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
onClick={async () => {
if (!accessToken) return;
try {
// Silently attempt to persist the current form values so the
// backend can read TEST_EMAIL_ADDRESS from the DB (DB mode).
// If saving is not supported (e.g. STORE_MODEL_IN_DB=False /
// YAML mode), this is a no-op and the backend will fall back to
// the TEST_EMAIL_ADDRESS environment variable instead.
await handleSaveEmailSettings({ silent: true });
await serviceHealthCheck(accessToken, "email");
NotificationManager.success("Email test triggered. Check your configured email inbox/logs.");
} catch (error) {
Expand Down
Loading