Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ async def send_user_invitation_email(self, event: WebhookEvent):
email_html_content = USER_INVITATION_EMAIL_TEMPLATE.format(
email_logo_url=email_params.logo_url,
recipient_email=email_params.recipient_email,
invitation_link=email_params.base_url,
base_url=email_params.base_url,
email_support_contact=email_params.support_contact,
email_footer=email_params.signature,
Expand Down Expand Up @@ -826,10 +827,15 @@ async def _get_invitation_link(self, user_id: Optional[str], base_url: str) -> s
"""
# Early validation
if not user_id:
verbose_proxy_logger.debug("No user_id provided for invitation link")
verbose_proxy_logger.warning(
"No user_id provided for invitation link. Email will link to base URL instead of onboarding page"
)
return base_url

if not await self._is_prisma_client_available():
verbose_proxy_logger.warning(
"Prisma client not available. Email will link to base URL instead of onboarding page"
)
return base_url

# Wait for any concurrent invitation creation to complete
Expand All @@ -839,11 +845,15 @@ async def _get_invitation_link(self, user_id: Optional[str], base_url: str) -> s
invitation = await self._get_or_create_invitation(user_id)
if not invitation:
verbose_proxy_logger.warning(
f"Failed to get/create invitation for user_id: {user_id}"
f"Failed to get/create invitation for user_id: {user_id}. Email will link to base URL instead of onboarding page"
)
return base_url

return self._construct_invitation_link(invitation.id, base_url)
invitation_link = self._construct_invitation_link(invitation.id, base_url)
verbose_proxy_logger.info(
Comment thread
veria-ai[bot] marked this conversation as resolved.
f"Successfully created invitation link for user_id: {user_id}"
)
Comment thread
mubashir1osmani marked this conversation as resolved.
return invitation_link

async def _is_prisma_client_available(self) -> bool:
"""Check if Prisma client is available"""
Expand Down Expand Up @@ -921,7 +931,9 @@ def _construct_invitation_link(self, invitation_id: str, base_url: str) -> str:

# http://localhost:4000/ui/onboarding?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b
"""
return f"{base_url}/ui/onboarding?invitation_id={invitation_id}"
base_url = base_url.rstrip("/")
invitation_link = f"{base_url}/ui/onboarding?invitation_id={invitation_id}"
Comment thread
mubashir1osmani marked this conversation as resolved.
return invitation_link

async def send_email(
self,
Expand Down
48 changes: 47 additions & 1 deletion litellm/integrations/SlackAlerting/slack_alerting.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import TYPE_CHECKING, Any, Final, Literal

from openai import APIError
from pydantic import TypeAdapter

import litellm
import litellm.litellm_core_utils
Expand All @@ -33,10 +34,14 @@
from litellm.proxy._types import (
AlertType,
CallInfo,
InvitationModel,
InvitationNew,
Litellm_EntityType,
UserAPIKeyAuth,
VirtualKeyEvent,
WebhookEvent,
)
from litellm.repositories.table_repositories import InvitationLinkRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.types.integrations.slack_alerting import *
Expand Down Expand Up @@ -1081,6 +1086,44 @@ async def _check_if_using_premium_email_feature(
if email_logo_url is not None or email_support_contact is not None:
raise ValueError(f"Trying to Customize Email Alerting\n {CommonProxyErrors.not_premium_user.value}")

async def _construct_user_invitation_link(self, recipient_user_id: str | None, base_url: str) -> str:
from litellm.proxy.management_helpers.user_invitation import (
create_invitation_for_user,
)
from litellm.proxy.proxy_server import prisma_client

if recipient_user_id is None or prisma_client is None:
return base_url

try:
existing_invitations: Final = TypeAdapter(list[InvitationModel]).validate_python(
await InvitationLinkRepository(prisma_client).table.find_many( # pyright: ignore[reportAny] # untyped prisma boundary (any-ok), result validated by TypeAdapter
where={"user_id": recipient_user_id}, # mutable-ok: prisma find_many requires a dict where filter
order={"created_at": "desc"}, # mutable-ok: prisma find_many requires a dict order arg
),
from_attributes=True,
)
invitation: Final = (
existing_invitations[0]
if existing_invitations
else TypeAdapter(InvitationModel).validate_python(
await create_invitation_for_user(
data=InvitationNew(user_id=recipient_user_id),
user_api_key_dict=UserAPIKeyAuth(user_id=recipient_user_id),
),
from_attributes=True,
)
)
except Exception as e: # noqa: BLE001 # best-effort link build; any DB/creation failure falls back to base_url
verbose_proxy_logger.error(
"Error creating invitation link for user_id %s: %s",
recipient_user_id,
str(e),
)
return base_url

return f"{base_url.rstrip('/')}/ui/onboarding?invitation_id={invitation.id}"

async def send_key_created_or_user_invited_email(self, webhook_event: WebhookEvent) -> bool:
try:
from litellm.proxy.utils import send_email
Expand Down Expand Up @@ -1139,11 +1182,14 @@ async def send_key_created_or_user_invited_email(self, webhook_event: WebhookEve
team_row: Final = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id})
if team_row is not None:
team_name = team_row.team_alias or "-"
invitation_link: Final = await self._construct_user_invitation_link(
recipient_user_id=recipient_user_id, base_url=base_url
)
email_html_content = USER_INVITED_EMAIL_TEMPLATE.format(
email_logo_url=email_logo_url,
recipient_email=recipient_email,
team_name=team_name,
base_url=base_url,
base_url=invitation_link,
email_support_contact=email_support_contact,
)
else:
Expand Down
2 changes: 1 addition & 1 deletion litellm/integrations/email_templates/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@

You were invited to use OpenAI Proxy API for team {team_name} <br /> <br />

<a href="{base_url}" style="display: inline-block; padding: 10px 20px; background-color: #87ceeb; color: #fff; text-decoration: none; border-radius: 20px;">Get Started here</a> <br /> <br />
<a href="{base_url}" style="display: inline-block; padding: 10px 20px; background-color: #87ceeb; color: #fff; text-decoration: none; border-radius: 20px;">Accept Invitation</a> <br /> <br />


If you have any questions, please send an email to {email_support_contact} <br /> <br />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
</div>

<div class="btn-container">
<a href="{base_url}" class="btn">Accept Invitation</a>
<a href="{invitation_link}" class="btn">Accept Invitation</a>
</div>

<div class="quickstart">
Expand Down
68 changes: 46 additions & 22 deletions litellm/proxy/hooks/user_management_event_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,42 +96,66 @@ async def async_send_user_invitation_email(
key_alias=response.key_alias,
)

sent_via_v2: Final = await UserManagementEventHooks._send_v2_user_invitation_emails(
event=event, send_invite_email=data.send_invite_email
)

#########################################################
########## V2 USER INVITATION EMAIL ################
########## LEGACY V1 USER INVITATION EMAIL (FALLBACK) ####
#########################################################
if data.send_invite_email is True and not sent_via_v2:
await UserManagementEventHooks.send_legacy_v1_user_invitation_email(
data=data,
response=response,
user_api_key_dict=user_api_key_dict,
event=event,
)

@staticmethod
async def _send_v2_user_invitation_emails(event: WebhookEvent, send_invite_email: bool | None) -> bool:
"""
Send the modern (V2) invitation email via any registered enterprise email logger.

Returns True if at least one logger delivered, so the caller only falls back to
the legacy email when V2 did not send (enterprise package absent, no email logger
configured, or every send raised).
"""
if send_invite_email is not True:
return False

try:
from litellm_enterprise.enterprise_callbacks.send_emails.base_email import (
BaseEmailLogger,
)

use_enterprise_email_hooks = True
except ImportError:
verbose_proxy_logger.warning(
"Defaulting to using Legacy Email Hooks." + CommonProxyErrors.missing_enterprise_package.value
)
use_enterprise_email_hooks = False
return False

if use_enterprise_email_hooks and (data.send_invite_email is True):
initialized_email_loggers: Final = litellm.logging_callback_manager.get_custom_loggers_for_type(
email_loggers: Final = tuple(
email_logger
for email_logger in litellm.logging_callback_manager.get_custom_loggers_for_type(
callback_type=BaseEmailLogger
)
if len(initialized_email_loggers) > 0:
for email_logger in initialized_email_loggers:
if isinstance(email_logger, BaseEmailLogger):
await email_logger.send_user_invitation_email(
event=event,
)
if isinstance(email_logger, BaseEmailLogger)
)
if len(email_loggers) == 0:
return False

#########################################################
########## LEGACY V1 USER INVITATION EMAIL ################
#########################################################
if data.send_invite_email is True:
await UserManagementEventHooks.send_legacy_v1_user_invitation_email(
data=data,
response=response,
user_api_key_dict=user_api_key_dict,
event=event,
)
send_outcomes: Final = await asyncio.gather(
*(email_logger.send_user_invitation_email(event=event) for email_logger in email_loggers),
return_exceptions=True,
)
for outcome in send_outcomes:
if isinstance(outcome, BaseException):
verbose_proxy_logger.error(
"Error sending v2 user invitation email for user_id=%s: %s",
event.user_id,
str(outcome),
)

return any(not isinstance(outcome, BaseException) for outcome in send_outcomes)

@staticmethod
async def send_legacy_v1_user_invitation_email(
Expand Down
34 changes: 10 additions & 24 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13977,11 +13977,8 @@ async def login(request: Request):

# Build redirect URL
litellm_dashboard_ui = get_custom_url(str(request.base_url))
if litellm_dashboard_ui.endswith("/"):
litellm_dashboard_ui += "ui/"
else:
litellm_dashboard_ui += "/ui/"
litellm_dashboard_ui += "?login=success"
litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/")
litellm_dashboard_ui += "/ui?login=success"

# Honor a same-origin return_to preserved by the sign-in page (e.g. the aggregate DCR connect flow's
# authorize round-trip), mirroring the SSO callback; otherwise land on the dashboard. Gated by
Expand Down Expand Up @@ -14051,11 +14048,8 @@ async def login_v2(request: Request):
jwt_token: Final = encode_ui_session_jwt(returned_ui_token_object, cast(str, master_key))

litellm_dashboard_ui = get_custom_url(str(request.base_url))
if litellm_dashboard_ui.endswith("/"):
litellm_dashboard_ui += "ui/"
else:
litellm_dashboard_ui += "/ui/"
litellm_dashboard_ui += "?login=success"
litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/")
litellm_dashboard_ui += "/ui?login=success"

# Token is included in the response body so the UI can set a JS-accessible
# cookie even when a reverse proxy (e.g. nginx-ingress) adds HttpOnly to the
Expand Down Expand Up @@ -14124,11 +14118,8 @@ async def login_v3(request: Request):
jwt_token: Final = encode_ui_session_jwt(returned_ui_token_object, cast(str, master_key))

litellm_dashboard_ui = get_custom_url(str(request.base_url))
if litellm_dashboard_ui.endswith("/"):
litellm_dashboard_ui += "ui/"
else:
litellm_dashboard_ui += "/ui/"
litellm_dashboard_ui += "?login=success"
litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/")
litellm_dashboard_ui += "/ui?login=success"

# Store JWT behind a single-use opaque code (60s TTL)
code: Final = secrets.token_urlsafe(32)
Expand Down Expand Up @@ -14276,10 +14267,8 @@ async def onboarding(invite_link: str, request: Request):
raise HTTPException(status_code=401, detail={"error": "User does not exist in db."})

litellm_dashboard_ui = get_custom_url(str(request.base_url))
if litellm_dashboard_ui.endswith("/"):
litellm_dashboard_ui += "ui/onboarding"
else:
litellm_dashboard_ui += "/ui/onboarding"
litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/")
litellm_dashboard_ui += "/ui/onboarding"
import jwt

user_email: Final = user_obj.user_email
Expand Down Expand Up @@ -14535,11 +14524,8 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request):
) from e

litellm_dashboard_ui = get_custom_url(str(request.base_url))
if litellm_dashboard_ui.endswith("/"):
litellm_dashboard_ui += "ui/"
else:
litellm_dashboard_ui += "/ui/"
litellm_dashboard_ui += "?login=success"
litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/")
litellm_dashboard_ui += "/ui?login=success"
return {
"login_url": litellm_dashboard_ui,
"token": jwt_token,
Expand Down
Loading
Loading