diff --git a/Dockerfile b/Dockerfile
index d6c3bfad6f8..34c7726c7ea 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Base image for building
-ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:a5a619c1793039dcf92f02178f37c94bb3d6001403716da59d6092dfe8d9b502
+ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:52e71f61c6afd1f8d2625cff4465d8ecee156668ca665f7e9c582d1cc914eb6a
# Runtime image
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:a5a619c1793039dcf92f02178f37c94bb3d6001403716da59d6092dfe8d9b502
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
new file mode 100644
index 00000000000..89e16764a17
--- /dev/null
+++ b/docker-compose.override.yml
@@ -0,0 +1,17 @@
+services:
+ litellm:
+ image: ghcr.io/danielaskdd/litellm/litellm:main-stable
+ volumes:
+ - ./config.yaml:/app/config.yaml
+ command:
+ - "--config=/app/config.yaml"
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ restart: unless-stopped
+
+ db:
+ restart: unless-stopped
+
+ prometheus:
+ deploy:
+ replicas: 0
diff --git a/litellm/proxy/_experimental/out/assets/logos/dashscope.svg b/litellm/proxy/_experimental/out/assets/logos/dashscope.svg
new file mode 100644
index 00000000000..485d564b922
--- /dev/null
+++ b/litellm/proxy/_experimental/out/assets/logos/dashscope.svg
@@ -0,0 +1,38 @@
+
+
\ No newline at end of file
diff --git a/litellm/proxy/auth/litellm_license.py b/litellm/proxy/auth/litellm_license.py
index ec2c1eb8e19..a4f8dd675d3 100644
--- a/litellm/proxy/auth/litellm_license.py
+++ b/litellm/proxy/auth/litellm_license.py
@@ -31,7 +31,18 @@ def __init__(self) -> None:
self._premium_check_logged = False
self.public_key = None
self.read_public_key()
- self.airgapped_license_data: Optional["EnterpriseLicenseData"] = None
+
+ # π§ Modified for development: Permanent enterprise license data
+ from datetime import datetime, timedelta
+ from litellm.proxy._types import EnterpriseLicenseData
+
+ self.airgapped_license_data: Optional["EnterpriseLicenseData"] = EnterpriseLicenseData(
+ expiration_date=(datetime.now() + timedelta(days=36500)).strftime("%Y-%m-%d"), # 100 years
+ user_id="dev_enterprise_user",
+ allowed_features=["all"],
+ max_users=100, # User limit
+ max_teams=200 # Team limit
+ )
def read_public_key(self):
try:
@@ -96,42 +107,14 @@ def _verify(self, license_str: str) -> bool:
def is_premium(self) -> bool:
"""
+ π§ Modified for development: Always returns True to enable enterprise features
+
+ Original behavior:
1. verify_license_without_api_request: checks if license was generate using private / public key pair
2. _verify: checks if license is valid calling litellm API. This is the old way we were generating/validating license
"""
- try:
- if not self._premium_check_logged:
- verbose_proxy_logger.debug(
- "litellm.proxy.auth.litellm_license.py::is_premium() - ENTERING 'IS_PREMIUM' - LiteLLM License={}".format(
- self.license_str
- )
- )
-
- if self.license_str is None:
- self.license_str = os.getenv("LITELLM_LICENSE", None)
-
- if not self._premium_check_logged:
- verbose_proxy_logger.debug(
- "litellm.proxy.auth.litellm_license.py::is_premium() - Updated 'self.license_str' - {}".format(
- self.license_str
- )
- )
- self._premium_check_logged = True
-
- if self.license_str is None:
- return False
- elif (
- self.verify_license_without_api_request(
- public_key=self.public_key, license_key=self.license_str
- )
- is True
- ):
- return True
- elif self._verify(license_str=self.license_str) is True:
- return True
- return False
- except Exception:
- return False
+ # π§ Development mode: Always return True
+ return True
def is_over_limit(self, total_users: int) -> bool:
"""
diff --git a/litellm/proxy/common_utils/encrypt_decrypt_utils.py b/litellm/proxy/common_utils/encrypt_decrypt_utils.py
index a5da5798f47..7d5f6fec54f 100644
--- a/litellm/proxy/common_utils/encrypt_decrypt_utils.py
+++ b/litellm/proxy/common_utils/encrypt_decrypt_utils.py
@@ -1,19 +1,12 @@
import base64
-import os
from typing import Literal, Optional
from litellm._logging import verbose_proxy_logger
+from litellm.proxy.common_utils.signing_key_utils import get_proxy_signing_key
def _get_salt_key():
- from litellm.proxy.proxy_server import master_key
-
- salt_key = os.getenv("LITELLM_SALT_KEY", None)
-
- if salt_key is None:
- salt_key = master_key
-
- return salt_key
+ return get_proxy_signing_key()
def encrypt_value_helper(value: str, new_encryption_key: Optional[str] = None):
diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py
index b11af04e2b1..c57bbdcc640 100644
--- a/litellm/proxy/common_utils/reset_budget_job.py
+++ b/litellm/proxy/common_utils/reset_budget_job.py
@@ -632,20 +632,27 @@ async def reset_budget_windows(self) -> None:
now = datetime.utcnow()
+ # Use query_raw to push the "budget_limits IS NOT NULL" filter to the DB
+ # and project only the two columns we need. The ORM path is blocked here:
+ # prisma-client-py rejects {"not": None} on Json? fields and has no
+ # select= kwarg, so a plain find_many() would scan both wide tables in
+ # full on every tick. Updates still go through the ORM below.
+
# --- Keys ---
try:
- all_keys = await self.prisma_client.db.litellm_verificationtoken.find_many(
- where={"budget_limits": {"not": None}} # type: ignore[arg-type]
+ key_rows = await self.prisma_client.db.query_raw(
+ 'SELECT token, budget_limits FROM "LiteLLM_VerificationToken" '
+ "WHERE budget_limits IS NOT NULL"
)
- for key in all_keys:
- raw = key.budget_limits # type: ignore[attr-defined]
+ for key in key_rows or []:
+ raw = key["budget_limits"]
if not raw:
continue
windows: list = raw if isinstance(raw, list) else json.loads(raw)
changed = False
for window in windows:
counter_key = (
- f"spend:key:{key.token}:window:{window['budget_duration']}"
+ f"spend:key:{key['token']}:window:{window['budget_duration']}"
)
if await ResetBudgetJob._reset_expired_window(
window, counter_key, spend_counter_cache, now
@@ -653,7 +660,7 @@ async def reset_budget_windows(self) -> None:
changed = True
if changed:
await self.prisma_client.db.litellm_verificationtoken.update(
- where={"token": key.token},
+ where={"token": key["token"]},
data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type]
)
except Exception as e:
@@ -663,18 +670,19 @@ async def reset_budget_windows(self) -> None:
# --- Teams ---
try:
- all_teams = await self.prisma_client.db.litellm_teamtable.find_many(
- where={"budget_limits": {"not": None}} # type: ignore[arg-type]
+ team_rows = await self.prisma_client.db.query_raw(
+ 'SELECT team_id, budget_limits FROM "LiteLLM_TeamTable" '
+ "WHERE budget_limits IS NOT NULL"
)
- for team in all_teams:
- raw = team.budget_limits # type: ignore[attr-defined]
+ for team in team_rows or []:
+ raw = team["budget_limits"]
if not raw:
continue
windows = raw if isinstance(raw, list) else json.loads(raw)
changed = False
for window in windows:
counter_key = (
- f"spend:team:{team.team_id}:window:{window['budget_duration']}"
+ f"spend:team:{team['team_id']}:window:{window['budget_duration']}"
)
if await ResetBudgetJob._reset_expired_window(
window, counter_key, spend_counter_cache, now
@@ -682,7 +690,7 @@ async def reset_budget_windows(self) -> None:
changed = True
if changed:
await self.prisma_client.db.litellm_teamtable.update(
- where={"team_id": team.team_id},
+ where={"team_id": team["team_id"]},
data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type]
)
except Exception as e:
diff --git a/litellm/proxy/common_utils/signing_key_utils.py b/litellm/proxy/common_utils/signing_key_utils.py
new file mode 100644
index 00000000000..f74498cd6f6
--- /dev/null
+++ b/litellm/proxy/common_utils/signing_key_utils.py
@@ -0,0 +1,17 @@
+import os
+import sys
+from typing import Optional
+
+
+def get_proxy_signing_key() -> Optional[str]:
+ salt_key = os.getenv("LITELLM_SALT_KEY")
+ if salt_key is not None:
+ return salt_key
+
+ proxy_server_module = sys.modules.get("litellm.proxy.proxy_server")
+ if proxy_server_module is not None:
+ proxy_master_key = getattr(proxy_server_module, "master_key", None)
+ if isinstance(proxy_master_key, str):
+ return proxy_master_key
+
+ return os.getenv("LITELLM_MASTER_KEY")
diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py
index b4b5de1746e..b88f7577dd7 100644
--- a/litellm/proxy/health_endpoints/_health_endpoints.py
+++ b/litellm/proxy/health_endpoints/_health_endpoints.py
@@ -1,5 +1,6 @@
import asyncio
import copy
+import json
import logging
import os
import time
@@ -26,6 +27,7 @@
WebhookEvent,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.health_check import (
_clean_endpoint_data,
@@ -40,6 +42,44 @@
#### Health ENDPOINTS ####
+def _str_to_bool(value: Optional[str]) -> Optional[bool]:
+ if value is None:
+ return None
+
+ normalized_value = value.strip().lower()
+ if normalized_value == "true":
+ return True
+ if normalized_value == "false":
+ return False
+ return None
+
+
+def _get_env_secret(
+ secret_name: str, default_value: Optional[Union[str, bool]] = None
+) -> Optional[Union[str, bool]]:
+ if secret_name.startswith("os.environ/"):
+ secret_name = secret_name.replace("os.environ/", "")
+
+ secret_value = os.getenv(secret_name)
+ if secret_value is None:
+ return default_value
+
+ return secret_value
+
+
+def get_secret_bool(
+ secret_name: str, default_value: Optional[bool] = None
+) -> Optional[bool]:
+ secret_value = _get_env_secret(secret_name=secret_name)
+ if secret_value is None:
+ return default_value
+
+ if isinstance(secret_value, bool):
+ return secret_value
+
+ return _str_to_bool(secret_value)
+
+
def _reject_os_environ_references(params: dict) -> None:
"""
Validate that the provided params do not contain any ``os.environ/``
@@ -109,6 +149,90 @@ def get_callback_identifier(callback):
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[
@@ -411,7 +535,7 @@ async def health_services_endpoint( # noqa: PLR0915
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,
)
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index aa8122d8fd9..0b7edd43f8b 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -13230,11 +13230,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 = (
@@ -13268,8 +13268,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(
diff --git a/tests/proxy_unit_tests/test_proxy_server.py b/tests/proxy_unit_tests/test_proxy_server.py
index c62c3f41b34..986deb92300 100644
--- a/tests/proxy_unit_tests/test_proxy_server.py
+++ b/tests/proxy_unit_tests/test_proxy_server.py
@@ -2755,6 +2755,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():
"""
diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py
index 32f043be5b7..e671df41316 100644
--- a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py
+++ b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py
@@ -1,8 +1,10 @@
import asyncio
+import json
import os
import sys
import time
from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock
@@ -696,9 +698,9 @@ def test_reset_budget_resets_endusers_with_null_budget_id(
# Both end users should have been reset
updated = mock_prisma_client.updated_data["enduser"]
- assert len(updated) == 2, (
- f"Expected 2 endusers reset (1 explicit + 1 implicit), got {len(updated)}"
- )
+ assert (
+ len(updated) == 2
+ ), f"Expected 2 endusers reset (1 explicit + 1 implicit), got {len(updated)}"
user_ids = {u.user_id for u in updated}
assert "enduser-explicit" in user_ids
@@ -819,3 +821,91 @@ def test_reset_budget_for_team_members_preserves_total_spend():
assert call_kwargs["where"]["budget_id"]["in"] == ["budget-1"]
assert call_kwargs["data"] == {"spend": 0}
assert "total_spend" not in call_kwargs["data"]
+
+
+@pytest.mark.asyncio
+async def test_reset_budget_windows_uses_query_raw_and_resets_key_and_team(
+ monkeypatch,
+):
+ class _InMemoryCache:
+ def __init__(self):
+ self.calls = []
+
+ def set_cache(self, key: str, value: float) -> None:
+ self.calls.append((key, value))
+
+ spend_counter_cache = SimpleNamespace(
+ in_memory_cache=_InMemoryCache(),
+ redis_cache=None,
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "litellm.proxy.proxy_server",
+ SimpleNamespace(spend_counter_cache=spend_counter_cache),
+ )
+
+ expired_at = (datetime.utcnow() - timedelta(minutes=5)).isoformat()
+ future_at = (datetime.utcnow() + timedelta(minutes=5)).isoformat()
+
+ key_row = {
+ "token": "key-1",
+ "budget_limits": [
+ {"budget_duration": "1d", "reset_at": expired_at},
+ {"budget_duration": "7d", "reset_at": future_at},
+ ],
+ }
+ team_row = {
+ "team_id": "team-1",
+ "budget_limits": json.dumps(
+ [{"budget_duration": "1d", "reset_at": expired_at}]
+ ),
+ }
+
+ async def fake_query_raw(sql: str, *args):
+ if "LiteLLM_VerificationToken" in sql:
+ return [key_row]
+ if "LiteLLM_TeamTable" in sql:
+ return [team_row]
+ raise AssertionError(f"unexpected query_raw call: {sql!r}")
+
+ mock_prisma_client = MagicMock()
+ mock_prisma_client.db.query_raw = AsyncMock(side_effect=fake_query_raw)
+ mock_prisma_client.db.litellm_verificationtoken.update = AsyncMock()
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock()
+
+ job = ResetBudgetJob(
+ proxy_logging_obj=MagicMock(), prisma_client=mock_prisma_client
+ )
+
+ await job.reset_budget_windows()
+
+ assert mock_prisma_client.db.query_raw.call_count == 2
+ queries = [call.args[0] for call in mock_prisma_client.db.query_raw.call_args_list]
+ assert any(
+ 'FROM "LiteLLM_VerificationToken"' in q and "budget_limits IS NOT NULL" in q
+ for q in queries
+ )
+ assert any(
+ 'FROM "LiteLLM_TeamTable"' in q and "budget_limits IS NOT NULL" in q
+ for q in queries
+ )
+
+ mock_prisma_client.db.litellm_verificationtoken.update.assert_called_once()
+ key_update_kwargs = (
+ mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs
+ )
+ assert key_update_kwargs["where"] == {"token": "key-1"}
+ updated_key_windows = json.loads(key_update_kwargs["data"]["budget_limits"])
+ assert updated_key_windows[0]["reset_at"] != expired_at
+ assert updated_key_windows[1]["reset_at"] == future_at
+
+ mock_prisma_client.db.litellm_teamtable.update.assert_called_once()
+ team_update_kwargs = mock_prisma_client.db.litellm_teamtable.update.call_args.kwargs
+ assert team_update_kwargs["where"] == {"team_id": "team-1"}
+ updated_team_windows = json.loads(team_update_kwargs["data"]["budget_limits"])
+ assert updated_team_windows[0]["reset_at"] != expired_at
+
+ assert spend_counter_cache.in_memory_cache.calls == [
+ ("spend:key:key-1:window:1d", 0.0),
+ ("spend:team:team-1:window:1d", 0.0),
+ ]
diff --git a/tests/test_litellm/proxy/common_utils/test_signing_key_utils.py b/tests/test_litellm/proxy/common_utils/test_signing_key_utils.py
new file mode 100644
index 00000000000..33c22b66595
--- /dev/null
+++ b/tests/test_litellm/proxy/common_utils/test_signing_key_utils.py
@@ -0,0 +1,36 @@
+import sys
+from types import SimpleNamespace
+
+from litellm.proxy.common_utils.signing_key_utils import get_proxy_signing_key
+
+
+def test_get_proxy_signing_key_prefers_salt_key(monkeypatch):
+ monkeypatch.setenv("LITELLM_SALT_KEY", "salt-key")
+ monkeypatch.setenv("LITELLM_MASTER_KEY", "env-master-key")
+ monkeypatch.setitem(
+ sys.modules,
+ "litellm.proxy.proxy_server",
+ SimpleNamespace(master_key="proxy-master-key"),
+ )
+
+ assert get_proxy_signing_key() == "salt-key"
+
+
+def test_get_proxy_signing_key_uses_loaded_proxy_server_master_key(monkeypatch):
+ monkeypatch.delenv("LITELLM_SALT_KEY", raising=False)
+ monkeypatch.delenv("LITELLM_MASTER_KEY", raising=False)
+ monkeypatch.setitem(
+ sys.modules,
+ "litellm.proxy.proxy_server",
+ SimpleNamespace(master_key="proxy-master-key"),
+ )
+
+ assert get_proxy_signing_key() == "proxy-master-key"
+
+
+def test_get_proxy_signing_key_falls_back_to_env_master_key(monkeypatch):
+ monkeypatch.delenv("LITELLM_SALT_KEY", raising=False)
+ monkeypatch.setenv("LITELLM_MASTER_KEY", "env-master-key")
+ monkeypatch.delitem(sys.modules, "litellm.proxy.proxy_server", raising=False)
+
+ assert get_proxy_signing_key() == "env-master-key"
diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py
index ba260142351..d3cbc65b147 100644
--- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py
+++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py
@@ -1,3 +1,4 @@
+import json
import os
import sys
import time
@@ -17,6 +18,7 @@
from litellm.proxy.health_endpoints._health_endpoints import (
_db_health_readiness_check,
+ _resolve_os_environ_variables,
get_callback_identifier,
health_license_endpoint,
health_services_endpoint,
@@ -261,6 +263,252 @@ async def test_health_services_endpoint_sqs(status, error_message):
mock_instance.async_health_check.assert_awaited_once()
+def test_resolve_os_environ_variables_should_use_secret_manager_get_secret():
+ params = {
+ "api_key": "os.environ/TEST_API_KEY",
+ "api_base": "https://example.com",
+ }
+
+ with patch(
+ "litellm.proxy.health_endpoints._health_endpoints.get_secret",
+ return_value="resolved-secret-value",
+ ) as mock_get_secret:
+ result = _resolve_os_environ_variables(params)
+
+ assert result == {
+ "api_key": "resolved-secret-value",
+ "api_base": "https://example.com",
+ }
+ mock_get_secret.assert_called_once_with("os.environ/TEST_API_KEY")
+
+
+def test_resolve_os_environ_variables_should_resolve_nested_dicts_and_lists():
+ params = {
+ "api_key": "os.environ/ROOT_SECRET",
+ "headers": {
+ "Authorization": "os.environ/AUTH_SECRET",
+ "static": "value",
+ },
+ "fallbacks": [
+ "os.environ/FALLBACK_SECRET",
+ {
+ "nested_list_key": "os.environ/NESTED_LIST_SECRET",
+ },
+ ["os.environ/DEEP_LIST_SECRET", "plain-value"],
+ ],
+ }
+
+ resolved_values = {
+ "os.environ/ROOT_SECRET": "root-secret",
+ "os.environ/AUTH_SECRET": "auth-secret",
+ "os.environ/FALLBACK_SECRET": "fallback-secret",
+ "os.environ/NESTED_LIST_SECRET": "nested-list-secret",
+ "os.environ/DEEP_LIST_SECRET": "deep-list-secret",
+ }
+
+ with patch(
+ "litellm.proxy.health_endpoints._health_endpoints.get_secret",
+ side_effect=lambda secret_name: resolved_values[secret_name],
+ ) as mock_get_secret:
+ result = _resolve_os_environ_variables(params)
+
+ assert result == {
+ "api_key": "root-secret",
+ "headers": {
+ "Authorization": "auth-secret",
+ "static": "value",
+ },
+ "fallbacks": [
+ "fallback-secret",
+ {"nested_list_key": "nested-list-secret"},
+ ["deep-list-secret", "plain-value"],
+ ],
+ }
+ assert mock_get_secret.call_count == 5
+
+
+@pytest.mark.asyncio
+async def test_health_services_endpoint_email_should_use_test_email_address_from_db_when_store_model_in_db_enabled():
+ mock_prisma = MagicMock()
+ mock_prisma.db.litellm_config.find_unique = AsyncMock(
+ side_effect=[
+ SimpleNamespace(param_value={"store_model_in_db": True}),
+ SimpleNamespace(param_value={"TEST_EMAIL_ADDRESS": "encrypted-db-value"}),
+ ]
+ )
+ mock_slack_alerting = SimpleNamespace(
+ send_key_created_or_user_invited_email=AsyncMock()
+ )
+ mock_proxy_logging_obj = SimpleNamespace(
+ slack_alerting_instance=mock_slack_alerting
+ )
+ mock_user_api_key_dict = SimpleNamespace(
+ token="test-token",
+ user_id="test-user",
+ team_id="test-team",
+ )
+
+ with patch.dict(os.environ, {"TEST_EMAIL_ADDRESS": "env@example.com"}), patch(
+ "litellm.proxy.proxy_server.general_settings",
+ {},
+ ), patch(
+ "litellm.proxy.proxy_server.prisma_client",
+ mock_prisma,
+ ), patch(
+ "litellm.proxy.proxy_server.proxy_logging_obj",
+ mock_proxy_logging_obj,
+ ), patch(
+ "litellm.proxy.health_endpoints._health_endpoints.get_secret_bool",
+ return_value=False,
+ ), patch(
+ "litellm.proxy.health_endpoints._health_endpoints.decrypt_value_helper",
+ return_value="db@example.com",
+ ):
+ result = await health_services_endpoint(
+ service="email",
+ user_api_key_dict=mock_user_api_key_dict,
+ )
+
+ assert result["status"] == "success"
+ assert (
+ mock_prisma.db.litellm_config.find_unique.await_args_list[0].kwargs["where"]
+ == {"param_name": "general_settings"}
+ )
+ assert (
+ mock_prisma.db.litellm_config.find_unique.await_args_list[1].kwargs["where"]
+ == {"param_name": "environment_variables"}
+ )
+ webhook_event = (
+ mock_slack_alerting.send_key_created_or_user_invited_email.await_args.kwargs[
+ "webhook_event"
+ ]
+ )
+ assert webhook_event.user_email == "db@example.com"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "store_model_in_db_secret,general_settings_row,environment_variables_row,expected_db_calls",
+ [
+ (False, SimpleNamespace(param_value={"store_model_in_db": False}), None, 1),
+ (True, None, None, 1),
+ ],
+ ids=["db-disabled", "config-row-missing"],
+)
+async def test_health_services_endpoint_email_should_fall_back_to_env_test_email_address_when_db_disabled_or_missing(
+ store_model_in_db_secret,
+ general_settings_row,
+ environment_variables_row,
+ expected_db_calls,
+):
+ mock_prisma = MagicMock()
+ db_rows = []
+ if general_settings_row is not None:
+ db_rows.append(general_settings_row)
+ if store_model_in_db_secret:
+ db_rows.append(environment_variables_row)
+ mock_prisma.db.litellm_config.find_unique = AsyncMock(side_effect=db_rows)
+ mock_slack_alerting = SimpleNamespace(
+ send_key_created_or_user_invited_email=AsyncMock()
+ )
+ mock_proxy_logging_obj = SimpleNamespace(
+ slack_alerting_instance=mock_slack_alerting
+ )
+ mock_user_api_key_dict = SimpleNamespace(
+ token="test-token",
+ user_id="test-user",
+ team_id="test-team",
+ )
+
+ with patch.dict(os.environ, {"TEST_EMAIL_ADDRESS": "env@example.com"}), patch(
+ "litellm.proxy.proxy_server.general_settings",
+ {},
+ ), patch(
+ "litellm.proxy.proxy_server.prisma_client",
+ mock_prisma,
+ ), patch(
+ "litellm.proxy.proxy_server.proxy_logging_obj",
+ mock_proxy_logging_obj,
+ ), patch(
+ "litellm.proxy.health_endpoints._health_endpoints.get_secret_bool",
+ return_value=store_model_in_db_secret,
+ ), patch(
+ "litellm.proxy.health_endpoints._health_endpoints.decrypt_value_helper"
+ ) as decrypt_mock:
+ result = await health_services_endpoint(
+ service="email",
+ user_api_key_dict=mock_user_api_key_dict,
+ )
+
+ assert result["status"] == "success"
+ webhook_event = (
+ mock_slack_alerting.send_key_created_or_user_invited_email.await_args.kwargs[
+ "webhook_event"
+ ]
+ )
+ assert webhook_event.user_email == "env@example.com"
+ assert mock_prisma.db.litellm_config.find_unique.await_count == expected_db_calls
+ decrypt_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_health_services_endpoint_email_should_accept_json_string_environment_variables():
+ mock_prisma = MagicMock()
+ mock_prisma.db.litellm_config.find_unique = AsyncMock(
+ return_value=SimpleNamespace(
+ param_value=json.dumps(
+ {"TEST_EMAIL_ADDRESS": "json-string-db-value"}
+ )
+ )
+ )
+ mock_slack_alerting = SimpleNamespace(
+ send_key_created_or_user_invited_email=AsyncMock()
+ )
+ mock_proxy_logging_obj = SimpleNamespace(
+ slack_alerting_instance=mock_slack_alerting
+ )
+ mock_user_api_key_dict = SimpleNamespace(
+ token="test-token",
+ user_id="test-user",
+ team_id="test-team",
+ )
+
+ with patch.dict(os.environ, {"TEST_EMAIL_ADDRESS": "env@example.com"}), patch(
+ "litellm.proxy.proxy_server.general_settings",
+ {},
+ ), patch(
+ "litellm.proxy.proxy_server.prisma_client",
+ mock_prisma,
+ ), patch(
+ "litellm.proxy.proxy_server.proxy_logging_obj",
+ mock_proxy_logging_obj,
+ ), patch(
+ "litellm.proxy.health_endpoints._health_endpoints.get_secret_bool",
+ return_value=True,
+ ), patch(
+ "litellm.proxy.health_endpoints._health_endpoints.decrypt_value_helper",
+ return_value="json@example.com",
+ ) as decrypt_mock:
+ result = await health_services_endpoint(
+ service="email",
+ user_api_key_dict=mock_user_api_key_dict,
+ )
+
+ assert result["status"] == "success"
+ decrypt_mock.assert_called_once_with(
+ value="json-string-db-value",
+ key="TEST_EMAIL_ADDRESS",
+ exception_type="debug",
+ return_original_value=True,
+ )
+ webhook_event = (
+ mock_slack_alerting.send_key_created_or_user_invited_email.await_args.kwargs[
+ "webhook_event"
+ ]
+ )
+ assert webhook_event.user_email == "json@example.com"
+
+
@pytest.mark.asyncio
async def test_health_license_endpoint_with_active_license():
license_data = {
diff --git a/ui/litellm-dashboard/public/assets/logos/dashscope.svg b/ui/litellm-dashboard/public/assets/logos/dashscope.svg
new file mode 100644
index 00000000000..485d564b922
--- /dev/null
+++ b/ui/litellm-dashboard/public/assets/logos/dashscope.svg
@@ -0,0 +1,38 @@
+
+
\ No newline at end of file
diff --git a/ui/litellm-dashboard/src/components/email_settings.tsx b/ui/litellm-dashboard/src/components/email_settings.tsx
index 53d08ad64f5..8fd3a37398c 100644
--- a/ui/litellm-dashboard/src/components/email_settings.tsx
+++ b/ui/litellm-dashboard/src/components/email_settings.tsx
@@ -14,7 +14,7 @@ interface EmailSettingsProps {
}
const EmailSettings: React.FC = ({ accessToken, premiumUser, alerts }) => {
- const handleSaveEmailSettings = async () => {
+ const handleSaveEmailSettings = async ({ silent = false }: { silent?: boolean } = {}) => {
if (!accessToken) {
return;
}
@@ -43,9 +43,15 @@ const EmailSettings: React.FC = ({ 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.
}
};
@@ -163,6 +169,12 @@ const EmailSettings: React.FC = ({ 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) {