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) {