feat: add key_rotation_email field to API types and email notification hooks

This commit is contained in:
Ishaan Jaffer 2026-03-31 16:38:02 -07:00
parent e1fa0ba13a
commit 65a8727814
9 changed files with 132 additions and 58 deletions

View file

@ -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 <your-master-key>' \
-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`
<Image
<Image
img={require('../../img/key_r.png')}
style={{width: '30%', display: 'block', margin: '0'}}
/>
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`
<Image
<Image
img={require('../../img/key_u.png')}
style={{width: '30%', display: 'block', margin: '0'}}
/>

View file

@ -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,
)

View file

@ -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

View file

@ -884,9 +884,9 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
allowed_cache_controls: Optional[list] = []
config: Optional[dict] = {}
permissions: Optional[dict] = {}
model_max_budget: Optional[
dict
] = {} # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {}
model_max_budget: Optional[dict] = (
{}
) # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {}
model_config = ConfigDict(protected_namespaces=())
model_rpm_limit: Optional[dict] = None
@ -954,6 +954,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
@ -1008,6 +1012,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")
@ -1028,9 +1033,9 @@ class RegenerateKeyRequest(GenerateKeyRequest):
spend: Optional[float] = None
metadata: Optional[dict] = None
new_master_key: Optional[str] = None
grace_period: Optional[
str
] = None # Duration to keep old key valid (e.g. "24h", "2d"); None = immediate revoke
grace_period: Optional[str] = (
None # Duration to keep old key valid (e.g. "24h", "2d"); None = immediate revoke
)
class ResetSpendRequest(LiteLLMPydanticObjectBase):
@ -1540,12 +1545,12 @@ class NewCustomerRequest(BudgetNewRequest):
blocked: bool = False # allow/disallow requests for this end-user
budget_id: Optional[str] = None # give either a budget_id or max_budget
spend: Optional[float] = None
allowed_model_region: Optional[
AllowedModelRegion
] = None # require all user requests to use models in this specific region
default_model: Optional[
str
] = None # if no equivalent model in allowed region - default all requests to this model
allowed_model_region: Optional[AllowedModelRegion] = (
None # require all user requests to use models in this specific region
)
default_model: Optional[str] = (
None # if no equivalent model in allowed region - default all requests to this model
)
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
@model_validator(mode="before")
@ -1568,12 +1573,12 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase):
blocked: bool = False # allow/disallow requests for this end-user
max_budget: Optional[float] = None
budget_id: Optional[str] = None # give either a budget_id or max_budget
allowed_model_region: Optional[
AllowedModelRegion
] = None # require all user requests to use models in this specific region
default_model: Optional[
str
] = None # if no equivalent model in allowed region - default all requests to this model
allowed_model_region: Optional[AllowedModelRegion] = (
None # require all user requests to use models in this specific region
)
default_model: Optional[str] = (
None # if no equivalent model in allowed region - default all requests to this model
)
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
@ -1663,15 +1668,15 @@ class NewTeamRequest(TeamBase):
] = None # raise an error if 'guaranteed_throughput' is set and we're overallocating tpm
model_tpm_limit: Optional[Dict[str, int]] = None
team_member_budget: Optional[
float
] = None # allow user to set a budget for all team members
team_member_rpm_limit: Optional[
int
] = None # allow user to set RPM limit for all team members
team_member_tpm_limit: Optional[
int
] = None # allow user to set TPM limit for all team members
team_member_budget: Optional[float] = (
None # allow user to set a budget for all team members
)
team_member_rpm_limit: Optional[int] = (
None # allow user to set RPM limit for all team members
)
team_member_tpm_limit: Optional[int] = (
None # allow user to set TPM limit for all team members
)
team_member_key_duration: Optional[str] = None # e.g. "1d", "1w", "1m"
team_member_budget_duration: Optional[str] = None # e.g. "30d", "1mo"
allowed_vector_store_indexes: Optional[List[AllowedVectorStoreIndexItem]] = None
@ -1768,9 +1773,9 @@ class BlockKeyRequest(LiteLLMPydanticObjectBase):
class AddTeamCallback(LiteLLMPydanticObjectBase):
callback_name: str
callback_type: Optional[
Literal["success", "failure", "success_and_failure"]
] = "success_and_failure"
callback_type: Optional[Literal["success", "failure", "success_and_failure"]] = (
"success_and_failure"
)
callback_vars: Dict[str, str]
@model_validator(mode="before")
@ -2110,9 +2115,9 @@ class ConfigList(LiteLLMPydanticObjectBase):
stored_in_db: Optional[bool]
field_default_value: Any
premium_field: bool = False
nested_fields: Optional[
List[FieldDetail]
] = None # For nested dictionary or Pydantic fields
nested_fields: Optional[List[FieldDetail]] = (
None # For nested dictionary or Pydantic fields
)
class UserHeaderMapping(LiteLLMPydanticObjectBase):
@ -2360,6 +2365,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=())
@ -2470,9 +2478,9 @@ class UserAPIKeyAuth(
user_max_budget: Optional[float] = None
request_route: Optional[str] = None
user: Optional[Any] = None # Expanded user object when expand=user is used
created_by_user: Optional[
Any
] = None # Expanded created_by user when expand=user is used
created_by_user: Optional[Any] = (
None # Expanded created_by user when expand=user is used
)
end_user_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
# Decoded upstream IdP claims (groups, roles, etc.) propagated by JWT auth machinery
# and forwarded into outbound tokens by guardrails such as MCPJWTSigner.
@ -2611,9 +2619,9 @@ class LiteLLM_OrganizationMembershipTable(LiteLLMPydanticObjectBase):
budget_id: Optional[str] = None
created_at: datetime
updated_at: datetime
user: Optional[
Any
] = None # You might want to replace 'Any' with a more specific type if available
user: Optional[Any] = (
None # You might want to replace 'Any' with a more specific type if available
)
litellm_budget_table: Optional[LiteLLM_BudgetTable] = None
user_email: Optional[str] = None
@ -3764,9 +3772,9 @@ class TeamModelDeleteRequest(BaseModel):
# Organization Member Requests
class OrganizationMemberAddRequest(OrgMemberAddRequest):
organization_id: str
max_budget_in_organization: Optional[
float
] = None # Users max budget within the organization
max_budget_in_organization: Optional[float] = (
None # Users max budget within the organization
)
class OrganizationMemberDeleteRequest(MemberDeleteRequest):
@ -4017,9 +4025,9 @@ class ProviderBudgetResponse(LiteLLMPydanticObjectBase):
Maps provider names to their budget configs.
"""
providers: Dict[
str, ProviderBudgetResponseObject
] = {} # Dictionary mapping provider names to their budget configurations
providers: Dict[str, ProviderBudgetResponseObject] = (
{}
) # Dictionary mapping provider names to their budget configurations
class ProxyStateVariables(TypedDict):
@ -4163,9 +4171,9 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
enforce_rbac: bool = False
roles_jwt_field: Optional[str] = None # v2 on role mappings
role_mappings: Optional[List[RoleMapping]] = None
object_id_jwt_field: Optional[
str
] = None # can be either user / team, inferred from the role mapping
object_id_jwt_field: Optional[str] = (
None # can be either user / team, inferred from the role mapping
)
scope_mappings: Optional[List[ScopeMapping]] = None
enforce_scope_based_access: bool = False
enforce_team_based_model_access: bool = False

View file

@ -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,
)
##########################

View file

@ -1,6 +1,7 @@
{
"ignore": [],
"exclude": ["**/node_modules", "**/__pycache__", "litellm/types/utils.py", "litellm/proxy/_types.py"],
"extraPaths": ["enterprise"],
"reportMissingImports": false,
"reportPrivateImportUsage": false
}

View file

@ -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<KeyLifecycleSettingsProps> = ({
onAutoRotationChange,
rotationInterval,
onRotationIntervalChange,
keyRotationEmail = "",
onKeyRotationEmailChange,
isCreateMode = false,
neverExpire = false,
onNeverExpireChange,
@ -169,9 +173,27 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
)}
</div>
{autoRotationEnabled && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 flex items-center space-x-1">
<span>Rotation Notification Email</span>
<Tooltip title="Email to notify when this key is rotated. Leave empty to use the key owner's email. Useful for service accounts.">
<InfoCircleOutlined className="text-gray-400 cursor-help text-xs" />
</Tooltip>
</label>
<TextInput
name="key_rotation_email"
placeholder="alerts@yourteam.com (optional)"
className="w-full"
value={keyRotationEmail}
onValueChange={(val) => onKeyRotationEmailChange?.(val)}
/>
</div>
)}
{autoRotationEnabled && (
<div className="bg-blue-50 p-3 rounded-md text-sm text-blue-700">
When rotation occurs, you&apos;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.
</div>
)}

View file

@ -200,6 +200,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({});
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(false);
const [rotationInterval, setRotationInterval] = useState<string>("30d");
const [keyRotationEmail, setKeyRotationEmail] = useState<string>("");
const [routerSettings, setRouterSettings] = useState<RouterSettingsAccordionValue | null>(null);
const [routerSettingsKey, setRouterSettingsKey] = useState<number>(0);
const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]);
@ -424,6 +425,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ 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<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
onAutoRotationChange={setAutoRotationEnabled}
rotationInterval={rotationInterval}
onRotationIntervalChange={setRotationInterval}
keyRotationEmail={keyRotationEmail}
onKeyRotationEmailChange={setKeyRotationEmail}
isCreateMode={true}
/>
</div>

View file

@ -101,6 +101,7 @@ export function KeyEditView({
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string | null>(keyData.organization_id || null);
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(keyData.auto_rotate || false);
const [rotationInterval, setRotationInterval] = useState<string>(keyData.rotation_interval || "");
const [keyRotationEmail, setKeyRotationEmail] = useState<string>((keyData as any).key_rotation_email || "");
const [neverExpire, setNeverExpire] = useState<boolean>(!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({
<Form.Item name="rotation_interval" hidden>
<Input />
</Form.Item>
<Form.Item name="key_rotation_email" hidden>
<Input />
</Form.Item>
<div className="sticky z-10 bg-white p-4 border-t border-gray-200 bottom-[-1.5rem] inset-x-[-1.5rem]">
<div className="flex justify-end items-center gap-2">