Merge pull request #17775 from BerriAI/litellm_sendgrid

[Feature] Sendgrid integration
This commit is contained in:
yuneng-jiang
2025-12-11 09:19:41 -08:00
committed by GitHub
15 changed files with 227 additions and 8 deletions
@@ -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
+17
View File
@@ -68,6 +68,23 @@ litellm_settings:
callbacks: ["resend_email"]
```
</TabItem>
<TabItem value="sendgrid" label="SendGrid API">
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"]
```
</TabItem>
</Tabs>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
+2 -2
View File
@@ -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==",
+1
View File
@@ -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",
@@ -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)
@@ -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):
Generated
+4 -4
View File
@@ -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"
+1 -1
View File
@@ -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"}
+1 -1
View File
@@ -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
@@ -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 = "<p>Test email body</p>"
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="<p>Test email body</p>",
)
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 = "<p>Test email body</p>"
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"},
]