diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index eefdab926c..ac1b55f88f 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -819,6 +819,8 @@ router_settings: | SMTP_SENDER_LOGO | Logo used in emails sent via SMTP | SMTP_TLS | Flag to enable or disable TLS for SMTP connections | SMTP_USERNAME | Username for SMTP authentication (do not set if SMTP does not require auth) +| SENDGRID_API_KEY | API key for SendGrid email service +| SENDGRID_SENDER_EMAIL | Email address used as the sender in SendGrid email transactions | SPEND_LOGS_URL | URL for retrieving spend logs | SPEND_LOG_CLEANUP_BATCH_SIZE | Number of logs deleted per batch during cleanup. Default is 1000 | SSL_CERTIFICATE | Path to the SSL certificate file diff --git a/docs/my-website/docs/proxy/email.md b/docs/my-website/docs/proxy/email.md index da8fc57dee..e50cc47f5d 100644 --- a/docs/my-website/docs/proxy/email.md +++ b/docs/my-website/docs/proxy/email.md @@ -68,6 +68,23 @@ litellm_settings: callbacks: ["resend_email"] ``` + + + +Add `sendgrid_email` to your proxy config.yaml under `litellm_settings` + +set the following env variables + +```shell showLineNumbers +SENDGRID_API_KEY="SG.1234" +SENDGRID_SENDER_EMAIL="notifications@your-domain.com" +``` + +```yaml showLineNumbers title="proxy_config.yaml" +litellm_settings: + callbacks: ["sendgrid_email"] +``` + diff --git a/enterprise/dist/litellm_enterprise-0.1.24-py3-none-any.whl b/enterprise/dist/litellm_enterprise-0.1.24-py3-none-any.whl new file mode 100644 index 0000000000..a26b0458c9 Binary files /dev/null and b/enterprise/dist/litellm_enterprise-0.1.24-py3-none-any.whl differ diff --git a/enterprise/dist/litellm_enterprise-0.1.24.tar.gz b/enterprise/dist/litellm_enterprise-0.1.24.tar.gz new file mode 100644 index 0000000000..4361910f4b Binary files /dev/null and b/enterprise/dist/litellm_enterprise-0.1.24.tar.gz differ diff --git a/enterprise/dist/litellm_enterprise-0.1.25-py3-none-any.whl b/enterprise/dist/litellm_enterprise-0.1.25-py3-none-any.whl new file mode 100644 index 0000000000..bcc559d21b Binary files /dev/null and b/enterprise/dist/litellm_enterprise-0.1.25-py3-none-any.whl differ diff --git a/enterprise/dist/litellm_enterprise-0.1.25.tar.gz b/enterprise/dist/litellm_enterprise-0.1.25.tar.gz new file mode 100644 index 0000000000..4db1cf7ef5 Binary files /dev/null and b/enterprise/dist/litellm_enterprise-0.1.25.tar.gz differ diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/sendgrid_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/sendgrid_email.py new file mode 100644 index 0000000000..dfde9ce329 --- /dev/null +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/sendgrid_email.py @@ -0,0 +1,81 @@ +""" +LiteLLM x SendGrid email integration. + +Docs: https://docs.sendgrid.com/api-reference/mail-send/mail-send +""" + +import os +from typing import List + +from litellm._logging import verbose_logger +from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, + httpxSpecialProvider, +) + +from .base_email import BaseEmailLogger + + +SENDGRID_API_ENDPOINT = "https://api.sendgrid.com/v3/mail/send" + + +class SendGridEmailLogger(BaseEmailLogger): + """ + Send emails using SendGrid's Mail Send API. + + Required env vars: + - SENDGRID_API_KEY + """ + + def __init__(self): + self.async_httpx_client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.LoggingCallback + ) + self.sendgrid_api_key = os.getenv("SENDGRID_API_KEY") + self.sendgrid_sender_email = os.getenv("SENDGRID_SENDER_EMAIL") + verbose_logger.debug("SendGrid Email Logger initialized.") + + async def send_email( + self, + from_email: str, + to_email: List[str], + subject: str, + html_body: str, + ): + """ + Send an email via SendGrid. + """ + if not self.sendgrid_api_key: + raise ValueError("SENDGRID_API_KEY is not set") + + sender_email = self.sendgrid_sender_email or from_email + verbose_logger.debug( + f"Sending email via SendGrid from {sender_email} to {to_email} with subject {subject}" + ) + + payload = { + "from": {"email": sender_email}, + "personalizations": [ + { + "to": [{"email": email} for email in to_email], + "subject": subject, + } + ], + "content": [ + { + "type": "text/html", + "value": html_body, + } + ], + } + + response = await self.async_httpx_client.post( + url=SENDGRID_API_ENDPOINT, + json=payload, + headers={"Authorization": f"Bearer {self.sendgrid_api_key}"}, + ) + + verbose_logger.debug( + f"SendGrid response status={response.status_code}, body={response.text}" + ) + return \ No newline at end of file diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index 2305a5e635..2bcd8d33ad 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm-enterprise" -version = "0.1.23" +version = "0.1.25" description = "Package for LiteLLM Enterprise features" authors = ["BerriAI"] readme = "README.md" @@ -22,7 +22,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "0.1.23" +version = "0.1.25" version_files = [ "pyproject.toml:version", "../requirements.txt:litellm-enterprise==", diff --git a/litellm/__init__.py b/litellm/__init__.py index e9bfed2ed1..2c6f04a3ae 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -159,6 +159,7 @@ _custom_logger_compatible_callbacks_literal = Literal[ "anthropic_cache_control_hook", "generic_api", "resend_email", + "sendgrid_email", "smtp_email", "deepeval", "s3_v2", diff --git a/litellm/litellm_core_utils/custom_logger_registry.py b/litellm/litellm_core_utils/custom_logger_registry.py index fdc9f37455..fa2ff42e1d 100644 --- a/litellm/litellm_core_utils/custom_logger_registry.py +++ b/litellm/litellm_core_utils/custom_logger_registry.py @@ -102,6 +102,9 @@ class CustomLoggerRegistry: from litellm_enterprise.enterprise_callbacks.send_emails.resend_email import ( ResendEmailLogger, ) + from litellm_enterprise.enterprise_callbacks.send_emails.sendgrid_email import ( + SendGridEmailLogger, + ) from litellm_enterprise.enterprise_callbacks.send_emails.smtp_email import ( SMTPEmailLogger, ) @@ -114,6 +117,7 @@ class CustomLoggerRegistry: "pagerduty": PagerDutyAlerting, "generic_api": GenericAPILogger, "resend_email": ResendEmailLogger, + "sendgrid_email": SendGridEmailLogger, "smtp_email": SMTPEmailLogger, } CALLBACK_CLASS_STR_TO_CLASS_TYPE.update(enterprise_loggers) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 6e845c56b5..90edc6ec05 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -172,6 +172,9 @@ try: from litellm_enterprise.enterprise_callbacks.send_emails.resend_email import ( ResendEmailLogger, ) + from litellm_enterprise.enterprise_callbacks.send_emails.sendgrid_email import ( + SendGridEmailLogger, + ) from litellm_enterprise.enterprise_callbacks.send_emails.smtp_email import ( SMTPEmailLogger, ) @@ -190,6 +193,7 @@ except Exception as e: ) GenericAPILogger = CustomLogger # type: ignore ResendEmailLogger = CustomLogger # type: ignore + SendGridEmailLogger = CustomLogger # type: ignore SMTPEmailLogger = CustomLogger # type: ignore PagerDutyAlerting = CustomLogger # type: ignore EnterpriseCallbackControls = None # type: ignore @@ -3904,6 +3908,13 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915 resend_email_logger = ResendEmailLogger() _in_memory_loggers.append(resend_email_logger) return resend_email_logger # type: ignore + elif logging_integration == "sendgrid_email": + for callback in _in_memory_loggers: + if isinstance(callback, SendGridEmailLogger): + return callback + sendgrid_email_logger = SendGridEmailLogger() + _in_memory_loggers.append(sendgrid_email_logger) + return sendgrid_email_logger # type: ignore elif logging_integration == "smtp_email": for callback in _in_memory_loggers: if isinstance(callback, SMTPEmailLogger): @@ -4144,6 +4155,10 @@ def get_custom_logger_compatible_class( # noqa: PLR0915 for callback in _in_memory_loggers: if isinstance(callback, ResendEmailLogger): return callback + elif logging_integration == "sendgrid_email": + for callback in _in_memory_loggers: + if isinstance(callback, SendGridEmailLogger): + return callback elif logging_integration == "smtp_email": for callback in _in_memory_loggers: if isinstance(callback, SMTPEmailLogger): diff --git a/poetry.lock b/poetry.lock index 726033e1d6..f6d63506a4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2720,13 +2720,13 @@ files = [ [[package]] name = "litellm-enterprise" -version = "0.1.23" +version = "0.1.24" description = "Package for LiteLLM Enterprise features" optional = true python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" files = [ - {file = "litellm_enterprise-0.1.23-py3-none-any.whl", hash = "sha256:d803ce3ef79494f21447368f1f4e05669183714e5081da9c27a05b1770eb1422"}, - {file = "litellm_enterprise-0.1.23.tar.gz", hash = "sha256:0171e1d10c10b29e663d03a6b84c77465e58fd1923ecd0f89796622ffb5c7bb0"}, + {file = "litellm_enterprise-0.1.24-py3-none-any.whl", hash = "sha256:82548d0377282c8491d695e6b891e0930910ab410ac10f01773c13c263ecef3f"}, + {file = "litellm_enterprise-0.1.24.tar.gz", hash = "sha256:e009b9e1be09735c58458b356a9d2b942f468b4a934c0cb6ace8c43c6f43ba0f"}, ] [[package]] @@ -6973,4 +6973,4 @@ utils = ["numpydoc"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "fec0ac9f9222e9952c6244bf874fac20201ac1e14e435d3201611ab4f882c4d7" +content-hash = "ddc452ea7bacb386fe494f5a2b8f6bfa4715eb0a16ce43c23cff731076b2cc67" diff --git a/pyproject.toml b/pyproject.toml index ab67697465..8093ad9876 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3. mcp = {version = "^1.21.2", optional = true, python = ">=3.10"} litellm-proxy-extras = {version = "0.4.12", optional = true} rich = {version = "13.7.1", optional = true} -litellm-enterprise = {version = "0.1.23", optional = true} +litellm-enterprise = {version = "0.1.25", optional = true} diskcache = {version = "^5.6.1", optional = true} polars = {version = "^1.31.0", optional = true, python = ">=3.10"} semantic-router = {version = ">=0.1.12", optional = true, python = ">=3.9,<3.14"} diff --git a/requirements.txt b/requirements.txt index 604e58132f..0293d5579a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -64,4 +64,4 @@ soundfile==0.12.1 # for audio file processing ######################## # LITELLM ENTERPRISE DEPENDENCIES ######################## -litellm-enterprise==0.1.23 +litellm-enterprise==0.1.25 diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_sendgrid_email.py b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_sendgrid_email.py new file mode 100644 index 0000000000..4ecb4872aa --- /dev/null +++ b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_sendgrid_email.py @@ -0,0 +1,99 @@ +import os +import sys +import unittest.mock as mock + +import pytest +from httpx import Response + +sys.path.insert(0, os.path.abspath("../../..")) + +from litellm_enterprise.enterprise_callbacks.send_emails.sendgrid_email import ( + SendGridEmailLogger, +) + + +@pytest.fixture +def mock_env_vars(): + with mock.patch.dict(os.environ, {"SENDGRID_API_KEY": "test_api_key"}): + yield + + +@pytest.fixture +def mock_httpx_client(): + with mock.patch( + "litellm_enterprise.enterprise_callbacks.send_emails.sendgrid_email.get_async_httpx_client" + ) as mock_client: + mock_response = mock.AsyncMock(spec=Response) + mock_response.status_code = 202 + mock_response.text = "accepted" + + mock_async_client = mock.AsyncMock() + mock_async_client.post.return_value = mock_response + mock_client.return_value = mock_async_client + + yield mock_async_client + + +@pytest.mark.asyncio +async def test_send_email_success(mock_env_vars, mock_httpx_client): + logger = SendGridEmailLogger() + + from_email = "test@example.com" + to_email = ["recipient@example.com"] + subject = "Test Subject" + html_body = "

Test email body

" + + await logger.send_email( + from_email=from_email, to_email=to_email, subject=subject, html_body=html_body + ) + + mock_httpx_client.post.assert_called_once() + call_args = mock_httpx_client.post.call_args + assert call_args[1]["url"] == "https://api.sendgrid.com/v3/mail/send" + + payload = call_args[1]["json"] + assert payload["from"] == {"email": from_email} + assert payload["personalizations"][0]["to"] == [{"email": to_email[0]}] + assert payload["personalizations"][0]["subject"] == subject + assert payload["content"][0]["type"] == "text/html" + assert payload["content"][0]["value"] == html_body + + assert call_args[1]["headers"] == {"Authorization": "Bearer test_api_key"} + + +@pytest.mark.asyncio +async def test_send_email_missing_api_key(mock_httpx_client): + with mock.patch.dict(os.environ, {}, clear=True): + logger = SendGridEmailLogger() + + with pytest.raises(ValueError): + await logger.send_email( + from_email="test@example.com", + to_email=["recipient@example.com"], + subject="Test Subject", + html_body="

Test email body

", + ) + + mock_httpx_client.post.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_email_multiple_recipients(mock_env_vars, mock_httpx_client): + logger = SendGridEmailLogger() + + from_email = "test@example.com" + to_email = ["recipient1@example.com", "recipient2@example.com"] + subject = "Test Subject" + html_body = "

Test email body

" + + await logger.send_email( + from_email=from_email, to_email=to_email, subject=subject, html_body=html_body + ) + + mock_httpx_client.post.assert_called_once() + payload = mock_httpx_client.post.call_args[1]["json"] + + assert payload["personalizations"][0]["to"] == [ + {"email": "recipient1@example.com"}, + {"email": "recipient2@example.com"}, + ]