diff --git a/docs/my-website/docs/proxy/virtual_keys.md b/docs/my-website/docs/proxy/virtual_keys.md index c74aa75ff4a..3c149619ee3 100644 --- a/docs/my-website/docs/proxy/virtual_keys.md +++ b/docs/my-website/docs/proxy/virtual_keys.md @@ -596,14 +596,32 @@ curl 'http://0.0.0.0:4000/key/generate' \ }' ``` +**Set a custom rotation notification email (useful for service accounts)** + +By default, rotation emails go to the key owner. Use `key_rotation_email` to send notifications to a specific address instead — useful when the key is owned by a service account with no real inbox. + +```bash +curl 'http://0.0.0.0:4000/key/generate' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "models": ["gpt-4o"], + "auto_rotate": true, + "rotation_interval": "30d", + "key_rotation_email": "platform-alerts@yourcompany.com" + }' +``` + **LiteLLM UI** On the LiteLLM UI, Navigate to the Keys page and click on `Generate Key` > `Key Lifecycle` > `Enable Auto Rotation` - +When auto-rotation is enabled, a **Rotation Notification Email** field appears. Leave it empty to use the key owner's email, or enter a specific address (e.g. a team alias or alert inbox). + **Valid rotation_interval formats:** - `"30s"` - 30 seconds - `"30m"` - 30 minutes @@ -622,7 +640,8 @@ curl 'http://0.0.0.0:4000/key/update' \ -d '{ "key": "sk-existing-key", "auto_rotate": true, - "rotation_interval": "90d" + "rotation_interval": "90d", + "key_rotation_email": "platform-alerts@yourcompany.com" }' ``` @@ -630,7 +649,7 @@ curl 'http://0.0.0.0:4000/key/update' \ On the LiteLLM UI, Navigate to the Keys page. Select the key you want to update and click on `Edit Settings` > `Auto-Rotation Settings` - diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py index 7a77898b160..70872044e41 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py @@ -158,7 +158,8 @@ class BaseEmailLogger(CustomLogger): """ email_params = await self._get_email_params( user_id=send_key_rotated_email_event.user_id, - user_email=send_key_rotated_email_event.user_email, + user_email=send_key_rotated_email_event.key_rotation_email + or send_key_rotated_email_event.user_email, email_event=EmailEvent.virtual_key_rotated, event_message=send_key_rotated_email_event.event_message, ) diff --git a/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py b/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py index 380b0a6facb..4f7b766cfc0 100644 --- a/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py +++ b/enterprise/litellm_enterprise/types/enterprise_callbacks/send_emails.py @@ -26,6 +26,7 @@ class SendKeyCreatedEmailEvent(WebhookEvent): class SendKeyRotatedEmailEvent(WebhookEvent): virtual_key: str key_alias: Optional[str] = None + key_rotation_email: Optional[str] = None """ The virtual key that was rotated this will be sk-123xxx, since we will be emailing this to the user to start using the new key diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260331000000_add_key_rotation_email/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260331000000_add_key_rotation_email/migration.sql new file mode 100644 index 00000000000..6b93050dbdc --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260331000000_add_key_rotation_email/migration.sql @@ -0,0 +1,7 @@ +-- Add key_rotation_email to LiteLLM_VerificationToken and LiteLLM_DeletedVerificationToken +-- AlterTable +ALTER TABLE "LiteLLM_VerificationToken" + ADD COLUMN IF NOT EXISTS "key_rotation_email" TEXT; + +ALTER TABLE "LiteLLM_DeletedVerificationToken" + ADD COLUMN IF NOT EXISTS "key_rotation_email" TEXT; diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 6020fe0076e..38d55359873 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -955,6 +955,10 @@ class GenerateKeyRequest(KeyRequestBase): default=None, description="How often to rotate this key (e.g., '30d', '90d'). Required if auto_rotate=True", ) + key_rotation_email: Optional[str] = Field( + default=None, + description="Email to notify when key is rotated. Overrides key owner email. Useful for service accounts.", + ) organization_id: Optional[str] = None project_id: Optional[str] = None @@ -1009,6 +1013,7 @@ class UpdateKeyRequest(KeyRequestBase): temp_budget_expiry: Optional[datetime] = None auto_rotate: Optional[bool] = None rotation_interval: Optional[str] = None + key_rotation_email: Optional[str] = None organization_id: Optional[str] = None @model_validator(mode="after") @@ -2361,6 +2366,9 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase): rotation_interval: Optional[str] = None # How often to rotate (e.g., "30d", "90d") last_rotation_at: Optional[datetime] = None # When this key was last rotated key_rotation_at: Optional[datetime] = None # When this key should next be rotated + key_rotation_email: Optional[str] = ( + None # Override email for rotation notifications + ) router_settings: Optional[dict] = None model_config = ConfigDict(protected_namespaces=()) diff --git a/litellm/proxy/hooks/key_management_event_hooks.py b/litellm/proxy/hooks/key_management_event_hooks.py index 2d61203ad51..8d42cac6f41 100644 --- a/litellm/proxy/hooks/key_management_event_hooks.py +++ b/litellm/proxy/hooks/key_management_event_hooks.py @@ -178,6 +178,9 @@ class KeyManagementEventHooks: await KeyManagementEventHooks._send_key_rotated_email( response=response.model_dump(exclude_none=True), existing_key_alias=existing_key_row.key_alias, + key_rotation_email=getattr( + existing_key_row, "key_rotation_email", None + ), ) except Exception as e: verbose_proxy_logger.warning(f"Failed to send key rotated email: {e}") @@ -364,10 +367,10 @@ class KeyManagementEventHooks: if key.key_alias is not None: team_id = getattr(key, "team_id", None) if team_id not in team_settings_cache: - team_settings_cache[ - team_id - ] = await KeyManagementEventHooks._get_secret_manager_optional_params( - team_id + team_settings_cache[team_id] = ( + await KeyManagementEventHooks._get_secret_manager_optional_params( + team_id + ) ) optional_params = team_settings_cache[team_id] await litellm.secret_manager_client.async_delete_secret( @@ -542,7 +545,9 @@ class KeyManagementEventHooks: @staticmethod async def _send_key_rotated_email( - response: dict, existing_key_alias: Optional[str] + response: dict, + existing_key_alias: Optional[str], + key_rotation_email: Optional[str] = None, ): """ Send key rotated email if email sending is enabled. @@ -589,6 +594,7 @@ class KeyManagementEventHooks: user_id=response.get("user_id", None), team_id=response.get("team_id", "Default Team"), key_alias=response.get("key_alias", existing_key_alias), + key_rotation_email=key_rotation_email, ) ########################## diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 180c50206ed..52a728bf48f 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -2798,6 +2798,7 @@ async def generate_key_helper_fn( # noqa: PLR0915 object_permission: Optional[LiteLLM_ObjectPermissionBase] = None, auto_rotate: Optional[bool] = None, rotation_interval: Optional[str] = None, + key_rotation_email: Optional[str] = None, router_settings: Optional[dict] = None, access_group_ids: Optional[list] = None, ): @@ -2928,6 +2929,9 @@ async def generate_key_helper_fn( # noqa: PLR0915 rotation_interval=rotation_interval, ) + if key_rotation_email is not None: + key_data["key_rotation_email"] = key_rotation_email + if ( get_secret("DISABLE_KEY_NAME", False) is True ): # allow user to disable storing abbreviated key name (shown in UI, to help figure out which key spent how much) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 519243e166d..edcf4e7d93f 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -387,6 +387,7 @@ model LiteLLM_VerificationToken { rotation_interval String? // How often to rotate (e.g., "30d", "90d") last_rotation_at DateTime? // When this key was last rotated key_rotation_at DateTime? // When this key should next be rotated + key_rotation_email String? // Override email for rotation notifications (useful for service accounts) litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id]) litellm_project_table LiteLLM_ProjectTable? @relation(fields: [project_id], references: [project_id]) @@ -481,7 +482,8 @@ model LiteLLM_DeletedVerificationToken { rotation_interval String? last_rotation_at DateTime? key_rotation_at DateTime? - + key_rotation_email String? + // Deletion metadata deleted_at DateTime @default(now()) @map("deleted_at") deleted_by String? @map("deleted_by") // User who deleted the key diff --git a/pyrightconfig.json b/pyrightconfig.json index f930e44d305..f40605b7ea0 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,6 +1,7 @@ { "ignore": [], "exclude": ["**/node_modules", "**/__pycache__", "litellm/types/utils.py", "litellm/proxy/_types.py"], + "extraPaths": ["enterprise"], "reportMissingImports": false, "reportPrivateImportUsage": false } diff --git a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx index 17d65e1f66a..c1efcde0965 100644 --- a/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx +++ b/ui/litellm-dashboard/src/components/common_components/KeyLifecycleSettings.tsx @@ -11,6 +11,8 @@ interface KeyLifecycleSettingsProps { onAutoRotationChange: (enabled: boolean) => void; rotationInterval: string; onRotationIntervalChange: (interval: string) => void; + keyRotationEmail?: string; + onKeyRotationEmailChange?: (email: string) => void; isCreateMode?: boolean; // If true, shows "leave empty to never expire" instead of "-1 to never expire" neverExpire?: boolean; onNeverExpireChange?: (checked: boolean) => void; @@ -22,6 +24,8 @@ const KeyLifecycleSettings: React.FC = ({ onAutoRotationChange, rotationInterval, onRotationIntervalChange, + keyRotationEmail = "", + onKeyRotationEmailChange, isCreateMode = false, neverExpire = false, onNeverExpireChange, @@ -169,9 +173,27 @@ const KeyLifecycleSettings: React.FC = ({ )} + {autoRotationEnabled && ( +
+ + onKeyRotationEmailChange?.(val)} + /> +
+ )} + {autoRotationEnabled && (
- When rotation occurs, you'll receive a notification with the new key. The old key will be deactivated + When rotation occurs, a notification with the new key will be sent to the configured email (or the key owner if none set). The old key will be deactivated after a brief grace period.
)} diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index a681e438cd1..9eb77af5b2f 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -93,6 +93,7 @@ export interface KeyResponse { access_group_ids?: string[]; auto_rotate?: boolean; rotation_interval?: string; + key_rotation_email?: string; last_rotation_at?: string; key_rotation_at?: string; next_rotation_at?: string; diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 76888262e5a..d2d4b10c579 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -200,6 +200,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({}); const [autoRotationEnabled, setAutoRotationEnabled] = useState(false); const [rotationInterval, setRotationInterval] = useState("30d"); + const [keyRotationEmail, setKeyRotationEmail] = useState(""); const [routerSettings, setRouterSettings] = useState(null); const [routerSettingsKey, setRouterSettingsKey] = useState(0); const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]); @@ -424,6 +425,9 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp if (autoRotationEnabled) { formValues.auto_rotate = true; formValues.rotation_interval = rotationInterval; + if (keyRotationEmail.trim()) { + formValues.key_rotation_email = keyRotationEmail.trim(); + } } // Handle duration field for key expiry - convert empty string to null @@ -1532,6 +1536,8 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp onAutoRotationChange={setAutoRotationEnabled} rotationInterval={rotationInterval} onRotationIntervalChange={setRotationInterval} + keyRotationEmail={keyRotationEmail} + onKeyRotationEmailChange={setKeyRotationEmail} isCreateMode={true} /> diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index cf431d10245..a338ac0bbbe 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -101,6 +101,7 @@ export function KeyEditView({ const [selectedOrganizationId, setSelectedOrganizationId] = useState(keyData.organization_id || null); const [autoRotationEnabled, setAutoRotationEnabled] = useState(keyData.auto_rotate || false); const [rotationInterval, setRotationInterval] = useState(keyData.rotation_interval || ""); + const [keyRotationEmail, setKeyRotationEmail] = useState(keyData.key_rotation_email || ""); const [neverExpire, setNeverExpire] = useState(!keyData.expires); const [isKeySaving, setIsKeySaving] = useState(false); const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations(); @@ -238,6 +239,10 @@ export function KeyEditView({ } }, [rotationInterval, form]); + useEffect(() => { + form.setFieldValue("key_rotation_email", keyRotationEmail || null); + }, [keyRotationEmail, form]); + // Fetch tags for selector useEffect(() => { const fetchTags = async () => { @@ -707,6 +712,8 @@ export function KeyEditView({ onAutoRotationChange={setAutoRotationEnabled} rotationInterval={rotationInterval} onRotationIntervalChange={setRotationInterval} + keyRotationEmail={keyRotationEmail} + onKeyRotationEmailChange={setKeyRotationEmail} neverExpire={neverExpire} onNeverExpireChange={setNeverExpire} /> @@ -732,6 +739,9 @@ export function KeyEditView({ +