litellm-enterprise 0.1.25__py3-none-any.whl → 0.1.27__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +236 -2
- litellm_enterprise/enterprise_callbacks/send_emails/resend_email.py +2 -1
- litellm_enterprise/enterprise_callbacks/send_emails/sendgrid_email.py +2 -1
- litellm_enterprise/enterprise_callbacks/send_emails/smtp_email.py +2 -1
- litellm_enterprise/proxy/hooks/managed_files.py +175 -6
- litellm_enterprise/types/enterprise_callbacks/send_emails.py +4 -0
- {litellm_enterprise-0.1.25.dist-info → litellm_enterprise-0.1.27.dist-info}/METADATA +1 -1
- {litellm_enterprise-0.1.25.dist-info → litellm_enterprise-0.1.27.dist-info}/RECORD +10 -10
- {litellm_enterprise-0.1.25.dist-info → litellm_enterprise-0.1.27.dist-info}/WHEEL +0 -0
- {litellm_enterprise-0.1.25.dist-info → litellm_enterprise-0.1.27.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -5,7 +5,7 @@ Base class for sending emails to user after creating keys or invite links
|
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
|
-
from typing import List, Optional
|
|
8
|
+
from typing import List, Literal, Optional
|
|
9
9
|
|
|
10
10
|
from litellm_enterprise.types.enterprise_callbacks.send_emails import (
|
|
11
11
|
EmailEvent,
|
|
@@ -15,6 +15,7 @@ from litellm_enterprise.types.enterprise_callbacks.send_emails import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
from litellm._logging import verbose_proxy_logger
|
|
18
|
+
from litellm.caching.caching import DualCache
|
|
18
19
|
from litellm.integrations.custom_logger import CustomLogger
|
|
19
20
|
from litellm.integrations.email_templates.email_footer import EMAIL_FOOTER
|
|
20
21
|
from litellm.integrations.email_templates.key_created_email import (
|
|
@@ -26,9 +27,17 @@ from litellm.integrations.email_templates.key_rotated_email import (
|
|
|
26
27
|
from litellm.integrations.email_templates.user_invitation_email import (
|
|
27
28
|
USER_INVITATION_EMAIL_TEMPLATE,
|
|
28
29
|
)
|
|
29
|
-
from litellm.
|
|
30
|
+
from litellm.integrations.email_templates.templates import (
|
|
31
|
+
MAX_BUDGET_ALERT_EMAIL_TEMPLATE,
|
|
32
|
+
SOFT_BUDGET_ALERT_EMAIL_TEMPLATE,
|
|
33
|
+
)
|
|
34
|
+
from litellm.proxy._types import CallInfo, InvitationNew, UserAPIKeyAuth, WebhookEvent
|
|
30
35
|
from litellm.secret_managers.main import get_secret_bool
|
|
31
36
|
from litellm.types.integrations.slack_alerting import LITELLM_LOGO_URL
|
|
37
|
+
from litellm.constants import (
|
|
38
|
+
EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE,
|
|
39
|
+
EMAIL_BUDGET_ALERT_TTL,
|
|
40
|
+
)
|
|
32
41
|
|
|
33
42
|
|
|
34
43
|
class BaseEmailLogger(CustomLogger):
|
|
@@ -40,6 +49,21 @@ class BaseEmailLogger(CustomLogger):
|
|
|
40
49
|
EmailEvent.virtual_key_rotated: "LiteLLM: {event_message}",
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
internal_usage_cache: Optional[DualCache] = None,
|
|
55
|
+
**kwargs,
|
|
56
|
+
):
|
|
57
|
+
"""
|
|
58
|
+
Initialize BaseEmailLogger
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
internal_usage_cache: DualCache instance for preventing duplicate alerts
|
|
62
|
+
**kwargs: Additional arguments passed to CustomLogger
|
|
63
|
+
"""
|
|
64
|
+
super().__init__(**kwargs)
|
|
65
|
+
self.internal_usage_cache = internal_usage_cache or DualCache()
|
|
66
|
+
|
|
43
67
|
async def send_user_invitation_email(self, event: WebhookEvent):
|
|
44
68
|
"""
|
|
45
69
|
Send email to user after inviting them to the team
|
|
@@ -154,6 +178,216 @@ class BaseEmailLogger(CustomLogger):
|
|
|
154
178
|
)
|
|
155
179
|
pass
|
|
156
180
|
|
|
181
|
+
async def send_soft_budget_alert_email(self, event: WebhookEvent):
|
|
182
|
+
"""
|
|
183
|
+
Send email to user when soft budget is crossed
|
|
184
|
+
"""
|
|
185
|
+
email_params = await self._get_email_params(
|
|
186
|
+
email_event=EmailEvent.soft_budget_crossed, # Reuse existing event type for subject template
|
|
187
|
+
user_id=event.user_id,
|
|
188
|
+
user_email=event.user_email,
|
|
189
|
+
event_message=event.event_message,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
verbose_proxy_logger.debug(
|
|
193
|
+
f"send_soft_budget_alert_email_event: {json.dumps(event.model_dump(exclude_none=True), indent=4, default=str)}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Format budget values
|
|
197
|
+
soft_budget_str = f"${event.soft_budget}" if event.soft_budget is not None else "N/A"
|
|
198
|
+
spend_str = f"${event.spend}" if event.spend is not None else "$0.00"
|
|
199
|
+
max_budget_info = ""
|
|
200
|
+
if event.max_budget is not None:
|
|
201
|
+
max_budget_info = f"<b>Maximum Budget:</b> ${event.max_budget} <br />"
|
|
202
|
+
|
|
203
|
+
email_html_content = SOFT_BUDGET_ALERT_EMAIL_TEMPLATE.format(
|
|
204
|
+
email_logo_url=email_params.logo_url,
|
|
205
|
+
recipient_email=email_params.recipient_email,
|
|
206
|
+
soft_budget=soft_budget_str,
|
|
207
|
+
spend=spend_str,
|
|
208
|
+
max_budget_info=max_budget_info,
|
|
209
|
+
base_url=email_params.base_url,
|
|
210
|
+
email_support_contact=email_params.support_contact,
|
|
211
|
+
)
|
|
212
|
+
await self.send_email(
|
|
213
|
+
from_email=self.DEFAULT_LITELLM_EMAIL,
|
|
214
|
+
to_email=[email_params.recipient_email],
|
|
215
|
+
subject=email_params.subject,
|
|
216
|
+
html_body=email_html_content,
|
|
217
|
+
)
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
async def send_max_budget_alert_email(self, event: WebhookEvent):
|
|
221
|
+
"""
|
|
222
|
+
Send email to user when max budget alert threshold is reached
|
|
223
|
+
"""
|
|
224
|
+
email_params = await self._get_email_params(
|
|
225
|
+
email_event=EmailEvent.max_budget_alert,
|
|
226
|
+
user_id=event.user_id,
|
|
227
|
+
user_email=event.user_email,
|
|
228
|
+
event_message=event.event_message,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
verbose_proxy_logger.debug(
|
|
232
|
+
f"send_max_budget_alert_email_event: {json.dumps(event.model_dump(exclude_none=True), indent=4, default=str)}"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Format budget values
|
|
236
|
+
spend_str = f"${event.spend}" if event.spend is not None else "$0.00"
|
|
237
|
+
max_budget_str = f"${event.max_budget}" if event.max_budget is not None else "N/A"
|
|
238
|
+
|
|
239
|
+
# Calculate percentage and alert threshold
|
|
240
|
+
percentage = int(EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE * 100)
|
|
241
|
+
alert_threshold_str = f"${event.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE:.2f}" if event.max_budget is not None else "N/A"
|
|
242
|
+
|
|
243
|
+
email_html_content = MAX_BUDGET_ALERT_EMAIL_TEMPLATE.format(
|
|
244
|
+
email_logo_url=email_params.logo_url,
|
|
245
|
+
recipient_email=email_params.recipient_email,
|
|
246
|
+
percentage=percentage,
|
|
247
|
+
spend=spend_str,
|
|
248
|
+
max_budget=max_budget_str,
|
|
249
|
+
alert_threshold=alert_threshold_str,
|
|
250
|
+
base_url=email_params.base_url,
|
|
251
|
+
email_support_contact=email_params.support_contact,
|
|
252
|
+
)
|
|
253
|
+
await self.send_email(
|
|
254
|
+
from_email=self.DEFAULT_LITELLM_EMAIL,
|
|
255
|
+
to_email=[email_params.recipient_email],
|
|
256
|
+
subject=email_params.subject,
|
|
257
|
+
html_body=email_html_content,
|
|
258
|
+
)
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
async def budget_alerts(
|
|
262
|
+
self,
|
|
263
|
+
type: Literal[
|
|
264
|
+
"token_budget",
|
|
265
|
+
"soft_budget",
|
|
266
|
+
"max_budget_alert",
|
|
267
|
+
"user_budget",
|
|
268
|
+
"team_budget",
|
|
269
|
+
"organization_budget",
|
|
270
|
+
"proxy_budget",
|
|
271
|
+
"projected_limit_exceeded",
|
|
272
|
+
],
|
|
273
|
+
user_info: CallInfo,
|
|
274
|
+
):
|
|
275
|
+
"""
|
|
276
|
+
Send a budget alert via email
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
type: The type of budget alert to send
|
|
280
|
+
user_info: The user info to send the alert for
|
|
281
|
+
"""
|
|
282
|
+
## PREVENTITIVE ALERTING ##
|
|
283
|
+
# - Alert once within 24hr period
|
|
284
|
+
# - Cache this information
|
|
285
|
+
# - Don't re-alert, if alert already sent
|
|
286
|
+
_cache: DualCache = self.internal_usage_cache
|
|
287
|
+
|
|
288
|
+
# percent of max_budget left to spend
|
|
289
|
+
if user_info.max_budget is None and user_info.soft_budget is None:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
# For soft_budget alerts, check if we've already sent an alert
|
|
293
|
+
if type == "soft_budget":
|
|
294
|
+
if user_info.soft_budget is not None and user_info.spend >= user_info.soft_budget:
|
|
295
|
+
# Generate cache key based on event type and identifier
|
|
296
|
+
_id = user_info.token or user_info.user_id or "default_id"
|
|
297
|
+
_cache_key = f"email_budget_alerts:soft_budget_crossed:{_id}"
|
|
298
|
+
|
|
299
|
+
# Check if we've already sent this alert
|
|
300
|
+
result = await _cache.async_get_cache(key=_cache_key)
|
|
301
|
+
if result is None:
|
|
302
|
+
# Create WebhookEvent for soft budget alert
|
|
303
|
+
event_message = f"Soft Budget Crossed - Total Soft Budget: ${user_info.soft_budget}"
|
|
304
|
+
webhook_event = WebhookEvent(
|
|
305
|
+
event="soft_budget_crossed",
|
|
306
|
+
event_message=event_message,
|
|
307
|
+
spend=user_info.spend,
|
|
308
|
+
max_budget=user_info.max_budget,
|
|
309
|
+
soft_budget=user_info.soft_budget,
|
|
310
|
+
token=user_info.token,
|
|
311
|
+
customer_id=user_info.customer_id,
|
|
312
|
+
user_id=user_info.user_id,
|
|
313
|
+
team_id=user_info.team_id,
|
|
314
|
+
team_alias=user_info.team_alias,
|
|
315
|
+
organization_id=user_info.organization_id,
|
|
316
|
+
user_email=user_info.user_email,
|
|
317
|
+
key_alias=user_info.key_alias,
|
|
318
|
+
projected_exceeded_date=user_info.projected_exceeded_date,
|
|
319
|
+
projected_spend=user_info.projected_spend,
|
|
320
|
+
event_group=user_info.event_group,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
await self.send_soft_budget_alert_email(webhook_event)
|
|
325
|
+
|
|
326
|
+
# Cache the alert to prevent duplicate sends
|
|
327
|
+
await _cache.async_set_cache(
|
|
328
|
+
key=_cache_key,
|
|
329
|
+
value="SENT",
|
|
330
|
+
ttl=EMAIL_BUDGET_ALERT_TTL,
|
|
331
|
+
)
|
|
332
|
+
except Exception as e:
|
|
333
|
+
verbose_proxy_logger.error(
|
|
334
|
+
f"Error sending soft budget alert email: {e}",
|
|
335
|
+
exc_info=True,
|
|
336
|
+
)
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
# For max_budget_alert, check if we've already sent an alert
|
|
340
|
+
if type == "max_budget_alert":
|
|
341
|
+
if user_info.max_budget is not None and user_info.spend is not None:
|
|
342
|
+
alert_threshold = user_info.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE
|
|
343
|
+
# Only alert if we've crossed the threshold but haven't exceeded max_budget yet
|
|
344
|
+
if user_info.spend >= alert_threshold and user_info.spend < user_info.max_budget:
|
|
345
|
+
# Generate cache key based on event type and identifier
|
|
346
|
+
_id = user_info.token or user_info.user_id or "default_id"
|
|
347
|
+
_cache_key = f"email_budget_alerts:max_budget_alert:{_id}"
|
|
348
|
+
# Check if we've already sent this alert
|
|
349
|
+
result = await _cache.async_get_cache(key=_cache_key)
|
|
350
|
+
if result is None:
|
|
351
|
+
# Calculate percentage
|
|
352
|
+
percentage = int(EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE * 100)
|
|
353
|
+
|
|
354
|
+
# Create WebhookEvent for max budget alert
|
|
355
|
+
event_message = f"Max Budget Alert - {percentage}% of Maximum Budget Reached"
|
|
356
|
+
webhook_event = WebhookEvent(
|
|
357
|
+
event="max_budget_alert",
|
|
358
|
+
event_message=event_message,
|
|
359
|
+
spend=user_info.spend,
|
|
360
|
+
max_budget=user_info.max_budget,
|
|
361
|
+
soft_budget=user_info.soft_budget,
|
|
362
|
+
token=user_info.token,
|
|
363
|
+
customer_id=user_info.customer_id,
|
|
364
|
+
user_id=user_info.user_id,
|
|
365
|
+
team_id=user_info.team_id,
|
|
366
|
+
team_alias=user_info.team_alias,
|
|
367
|
+
organization_id=user_info.organization_id,
|
|
368
|
+
user_email=user_info.user_email,
|
|
369
|
+
key_alias=user_info.key_alias,
|
|
370
|
+
projected_exceeded_date=user_info.projected_exceeded_date,
|
|
371
|
+
projected_spend=user_info.projected_spend,
|
|
372
|
+
event_group=user_info.event_group,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
await self.send_max_budget_alert_email(webhook_event)
|
|
377
|
+
|
|
378
|
+
# Cache the alert to prevent duplicate sends
|
|
379
|
+
await _cache.async_set_cache(
|
|
380
|
+
key=_cache_key,
|
|
381
|
+
value="SENT",
|
|
382
|
+
ttl=EMAIL_BUDGET_ALERT_TTL,
|
|
383
|
+
)
|
|
384
|
+
except Exception as e:
|
|
385
|
+
verbose_proxy_logger.error(
|
|
386
|
+
f"Error sending max budget alert email: {e}",
|
|
387
|
+
exc_info=True,
|
|
388
|
+
)
|
|
389
|
+
return
|
|
390
|
+
|
|
157
391
|
async def _get_email_params(
|
|
158
392
|
self,
|
|
159
393
|
email_event: EmailEvent,
|
|
@@ -19,7 +19,8 @@ RESEND_API_ENDPOINT = "https://api.resend.com/emails"
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class ResendEmailLogger(BaseEmailLogger):
|
|
22
|
-
def __init__(self):
|
|
22
|
+
def __init__(self, internal_usage_cache=None, **kwargs):
|
|
23
|
+
super().__init__(internal_usage_cache=internal_usage_cache, **kwargs)
|
|
23
24
|
self.async_httpx_client = get_async_httpx_client(
|
|
24
25
|
llm_provider=httpxSpecialProvider.LoggingCallback
|
|
25
26
|
)
|
|
@@ -27,7 +27,8 @@ class SendGridEmailLogger(BaseEmailLogger):
|
|
|
27
27
|
- SENDGRID_API_KEY
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
|
-
def __init__(self):
|
|
30
|
+
def __init__(self, internal_usage_cache=None, **kwargs):
|
|
31
|
+
super().__init__(internal_usage_cache=internal_usage_cache, **kwargs)
|
|
31
32
|
self.async_httpx_client = get_async_httpx_client(
|
|
32
33
|
llm_provider=httpxSpecialProvider.LoggingCallback
|
|
33
34
|
)
|
|
@@ -21,7 +21,8 @@ class SMTPEmailLogger(BaseEmailLogger):
|
|
|
21
21
|
- SMTP_SENDER_EMAIL
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
|
-
def __init__(self):
|
|
24
|
+
def __init__(self, internal_usage_cache=None, **kwargs):
|
|
25
|
+
super().__init__(internal_usage_cache=internal_usage_cache, **kwargs)
|
|
25
26
|
verbose_logger.debug("SMTP Email Logger initialized....")
|
|
26
27
|
|
|
27
28
|
async def send_email(
|
|
@@ -22,7 +22,6 @@ from litellm.proxy._types import (
|
|
|
22
22
|
)
|
|
23
23
|
from litellm.proxy.openai_files_endpoints.common_utils import (
|
|
24
24
|
_is_base64_encoded_unified_file_id,
|
|
25
|
-
convert_b64_uid_to_unified_uid,
|
|
26
25
|
get_batch_id_from_unified_batch_id,
|
|
27
26
|
get_model_id_from_unified_batch_id,
|
|
28
27
|
)
|
|
@@ -42,6 +41,10 @@ from litellm.types.utils import (
|
|
|
42
41
|
LLMResponseTypes,
|
|
43
42
|
SpecialEnums,
|
|
44
43
|
)
|
|
44
|
+
from litellm.proxy.openai_files_endpoints.common_utils import (
|
|
45
|
+
get_content_type_from_file_object,
|
|
46
|
+
normalize_mime_type_for_provider,
|
|
47
|
+
)
|
|
45
48
|
|
|
46
49
|
if TYPE_CHECKING:
|
|
47
50
|
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
|
@@ -108,6 +111,17 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|
|
108
111
|
|
|
109
112
|
if file_object is not None:
|
|
110
113
|
db_data["file_object"] = file_object.model_dump_json()
|
|
114
|
+
# Extract storage metadata from hidden params if present
|
|
115
|
+
hidden_params = getattr(file_object, "_hidden_params", {}) or {}
|
|
116
|
+
if "storage_backend" in hidden_params:
|
|
117
|
+
db_data["storage_backend"] = hidden_params["storage_backend"]
|
|
118
|
+
if "storage_url" in hidden_params:
|
|
119
|
+
db_data["storage_url"] = hidden_params["storage_url"]
|
|
120
|
+
|
|
121
|
+
verbose_logger.debug(
|
|
122
|
+
f"Storage metadata: storage_backend={db_data.get('storage_backend')}, "
|
|
123
|
+
f"storage_url={db_data.get('storage_url')}"
|
|
124
|
+
)
|
|
111
125
|
|
|
112
126
|
result = await self.prisma_client.db.litellm_managedfiletable.create(
|
|
113
127
|
data=db_data
|
|
@@ -268,7 +282,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|
|
268
282
|
)
|
|
269
283
|
return False
|
|
270
284
|
|
|
271
|
-
async def async_pre_call_hook(
|
|
285
|
+
async def async_pre_call_hook( # noqa: PLR0915
|
|
272
286
|
self,
|
|
273
287
|
user_api_key_dict: UserAPIKeyAuth,
|
|
274
288
|
cache: DualCache,
|
|
@@ -287,15 +301,31 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|
|
287
301
|
await self.check_managed_file_id_access(data, user_api_key_dict)
|
|
288
302
|
|
|
289
303
|
### HANDLE TRANSFORMATIONS ###
|
|
290
|
-
|
|
304
|
+
# Check both completion and acompletion call types
|
|
305
|
+
is_completion_call = (
|
|
306
|
+
call_type == CallTypes.completion.value
|
|
307
|
+
or call_type == CallTypes.acompletion.value
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
if is_completion_call:
|
|
291
311
|
messages = data.get("messages")
|
|
312
|
+
model = data.get("model", "")
|
|
292
313
|
if messages:
|
|
293
314
|
file_ids = self.get_file_ids_from_messages(messages)
|
|
294
315
|
if file_ids:
|
|
316
|
+
# Check if any files are stored in storage backends and need base64 conversion
|
|
317
|
+
# This is needed for Vertex AI/Gemini which requires base64 content
|
|
318
|
+
is_vertex_ai = model and ("vertex_ai" in model or "gemini" in model.lower())
|
|
319
|
+
if is_vertex_ai:
|
|
320
|
+
await self._convert_storage_files_to_base64(
|
|
321
|
+
messages=messages,
|
|
322
|
+
file_ids=file_ids,
|
|
323
|
+
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
324
|
+
)
|
|
325
|
+
|
|
295
326
|
model_file_id_mapping = await self.get_model_file_id_mapping(
|
|
296
327
|
file_ids, user_api_key_dict.parent_otel_span
|
|
297
328
|
)
|
|
298
|
-
|
|
299
329
|
data["model_file_id_mapping"] = model_file_id_mapping
|
|
300
330
|
elif call_type == CallTypes.aresponses.value or call_type == CallTypes.responses.value:
|
|
301
331
|
# Handle managed files in responses API input
|
|
@@ -720,9 +750,27 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|
|
720
750
|
model_id=model_id,
|
|
721
751
|
model_name=model_name,
|
|
722
752
|
)
|
|
723
|
-
|
|
753
|
+
|
|
754
|
+
# Fetch the actual file object for the output file
|
|
755
|
+
file_object = None
|
|
756
|
+
try:
|
|
757
|
+
# Use litellm to retrieve the file object from the provider
|
|
758
|
+
from litellm import afile_retrieve
|
|
759
|
+
file_object = await afile_retrieve(
|
|
760
|
+
custom_llm_provider=model_name.split("/")[0] if model_name and "/" in model_name else "openai",
|
|
761
|
+
file_id=original_output_file_id
|
|
762
|
+
)
|
|
763
|
+
verbose_logger.debug(
|
|
764
|
+
f"Successfully retrieved file object for output_file_id={original_output_file_id}"
|
|
765
|
+
)
|
|
766
|
+
except Exception as e:
|
|
767
|
+
verbose_logger.warning(
|
|
768
|
+
f"Failed to retrieve file object for output_file_id={original_output_file_id}: {str(e)}. Storing with None and will fetch on-demand."
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
await self.store_unified_file_id(
|
|
724
772
|
file_id=response.output_file_id,
|
|
725
|
-
file_object=
|
|
773
|
+
file_object=file_object,
|
|
726
774
|
litellm_parent_otel_span=user_api_key_dict.parent_otel_span,
|
|
727
775
|
model_mappings={model_id: original_output_file_id},
|
|
728
776
|
user_api_key_dict=user_api_key_dict,
|
|
@@ -865,3 +913,124 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|
|
865
913
|
)
|
|
866
914
|
else:
|
|
867
915
|
raise Exception(f"LiteLLM Managed File object with id={file_id} not found")
|
|
916
|
+
|
|
917
|
+
async def _convert_storage_files_to_base64(
|
|
918
|
+
self,
|
|
919
|
+
messages: List[AllMessageValues],
|
|
920
|
+
file_ids: List[str],
|
|
921
|
+
litellm_parent_otel_span: Optional[Span],
|
|
922
|
+
) -> None:
|
|
923
|
+
"""
|
|
924
|
+
Convert files stored in storage backends to base64 format for Vertex AI/Gemini.
|
|
925
|
+
|
|
926
|
+
This method checks if any managed files are stored in storage backends,
|
|
927
|
+
downloads them, and converts them to base64 format in the messages.
|
|
928
|
+
"""
|
|
929
|
+
# Check each file_id to see if it's stored in a storage backend
|
|
930
|
+
for file_id in file_ids:
|
|
931
|
+
# Check if this is a base64 encoded unified file ID
|
|
932
|
+
decoded_unified_file_id = _is_base64_encoded_unified_file_id(file_id)
|
|
933
|
+
|
|
934
|
+
if not decoded_unified_file_id:
|
|
935
|
+
continue
|
|
936
|
+
|
|
937
|
+
# Check database for storage backend info
|
|
938
|
+
# IMPORTANT: The database stores the base64 encoded unified_file_id (not the decoded version)
|
|
939
|
+
# So we query with the original file_id (which is base64 encoded)
|
|
940
|
+
db_file = await self.prisma_client.db.litellm_managedfiletable.find_first(
|
|
941
|
+
where={"unified_file_id": file_id}
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
if not db_file or not db_file.storage_backend or not db_file.storage_url:
|
|
945
|
+
continue
|
|
946
|
+
|
|
947
|
+
# File is stored in a storage backend, download and convert to base64
|
|
948
|
+
try:
|
|
949
|
+
from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend
|
|
950
|
+
|
|
951
|
+
storage_backend_name = db_file.storage_backend
|
|
952
|
+
storage_url = db_file.storage_url
|
|
953
|
+
|
|
954
|
+
# Get storage backend (uses same env vars as callback)
|
|
955
|
+
try:
|
|
956
|
+
storage_backend = get_storage_backend(storage_backend_name)
|
|
957
|
+
except ValueError as e:
|
|
958
|
+
verbose_logger.warning(
|
|
959
|
+
f"Storage backend '{storage_backend_name}' error for file {file_id}: {str(e)}"
|
|
960
|
+
)
|
|
961
|
+
continue
|
|
962
|
+
|
|
963
|
+
file_content = await storage_backend.download_file(storage_url)
|
|
964
|
+
|
|
965
|
+
# Determine content type from file object
|
|
966
|
+
content_type = self._get_content_type_from_file_object(db_file.file_object)
|
|
967
|
+
|
|
968
|
+
# Convert to base64
|
|
969
|
+
base64_data = base64.b64encode(file_content).decode("utf-8")
|
|
970
|
+
base64_data_uri = f"data:{content_type};base64,{base64_data}"
|
|
971
|
+
|
|
972
|
+
# Update messages to use base64 instead of file_id
|
|
973
|
+
self._update_messages_with_base64_data(messages, file_id, base64_data_uri, content_type)
|
|
974
|
+
except Exception as e:
|
|
975
|
+
verbose_logger.exception(
|
|
976
|
+
f"Error converting file {file_id} from storage backend to base64: {str(e)}"
|
|
977
|
+
)
|
|
978
|
+
# Continue with other files even if one fails
|
|
979
|
+
continue
|
|
980
|
+
|
|
981
|
+
def _get_content_type_from_file_object(self, file_object: Optional[Any]) -> str:
|
|
982
|
+
"""
|
|
983
|
+
Determine content type from file object.
|
|
984
|
+
|
|
985
|
+
Uses the MIME type utility for consistent detection and normalization.
|
|
986
|
+
|
|
987
|
+
Args:
|
|
988
|
+
file_object: The file object from the database (can be dict, JSON string, or None)
|
|
989
|
+
|
|
990
|
+
Returns:
|
|
991
|
+
str: MIME type (defaults to "application/octet-stream" if cannot be determined)
|
|
992
|
+
"""
|
|
993
|
+
# Use utility function for detection
|
|
994
|
+
content_type = get_content_type_from_file_object(file_object)
|
|
995
|
+
|
|
996
|
+
# Normalize for Gemini/Vertex AI (requires image/jpeg, not image/jpg)
|
|
997
|
+
content_type = normalize_mime_type_for_provider(content_type, provider="gemini")
|
|
998
|
+
|
|
999
|
+
return content_type
|
|
1000
|
+
|
|
1001
|
+
def _update_messages_with_base64_data(
|
|
1002
|
+
self,
|
|
1003
|
+
messages: List[AllMessageValues],
|
|
1004
|
+
file_id: str,
|
|
1005
|
+
base64_data_uri: str,
|
|
1006
|
+
content_type: str,
|
|
1007
|
+
) -> None:
|
|
1008
|
+
"""
|
|
1009
|
+
Update messages to replace file_id with base64 data URI.
|
|
1010
|
+
|
|
1011
|
+
Args:
|
|
1012
|
+
messages: List of messages to update
|
|
1013
|
+
file_id: The file ID to replace
|
|
1014
|
+
base64_data_uri: The base64 data URI to use as replacement
|
|
1015
|
+
content_type: The MIME type of the file (e.g., "image/jpeg", "application/pdf")
|
|
1016
|
+
"""
|
|
1017
|
+
for message in messages:
|
|
1018
|
+
if message.get("role") == "user":
|
|
1019
|
+
content = message.get("content")
|
|
1020
|
+
if content and isinstance(content, list):
|
|
1021
|
+
for element in content:
|
|
1022
|
+
if element.get("type") == "file":
|
|
1023
|
+
file_element = cast(ChatCompletionFileObject, element)
|
|
1024
|
+
file_element_file = file_element.get("file", {})
|
|
1025
|
+
|
|
1026
|
+
if file_element_file.get("file_id") == file_id:
|
|
1027
|
+
# Replace file_id with base64 data
|
|
1028
|
+
file_element_file["file_data"] = base64_data_uri
|
|
1029
|
+
# Set format to help Gemini determine mime type
|
|
1030
|
+
file_element_file["format"] = content_type
|
|
1031
|
+
# Remove file_id to ensure only file_data is used
|
|
1032
|
+
file_element_file.pop("file_id", None)
|
|
1033
|
+
|
|
1034
|
+
verbose_logger.debug(
|
|
1035
|
+
f"Converted file {file_id} from storage backend to base64 with format {content_type}"
|
|
1036
|
+
)
|
|
@@ -36,6 +36,8 @@ class EmailEvent(str, enum.Enum):
|
|
|
36
36
|
virtual_key_created = "Virtual Key Created"
|
|
37
37
|
new_user_invitation = "New User Invitation"
|
|
38
38
|
virtual_key_rotated = "Virtual Key Rotated"
|
|
39
|
+
soft_budget_crossed = "Soft Budget Crossed"
|
|
40
|
+
max_budget_alert = "Max Budget Alert"
|
|
39
41
|
|
|
40
42
|
class EmailEventSettings(BaseModel):
|
|
41
43
|
event: EmailEvent
|
|
@@ -51,6 +53,8 @@ class DefaultEmailSettings(BaseModel):
|
|
|
51
53
|
EmailEvent.virtual_key_created: True, # On by default
|
|
52
54
|
EmailEvent.new_user_invitation: True, # On by default
|
|
53
55
|
EmailEvent.virtual_key_rotated: True, # On by default
|
|
56
|
+
EmailEvent.soft_budget_crossed: True, # On by default
|
|
57
|
+
EmailEvent.max_budget_alert: True, # On by default
|
|
54
58
|
}
|
|
55
59
|
)
|
|
56
60
|
def to_dict(self) -> Dict[str, bool]:
|
|
@@ -99,11 +99,11 @@ litellm_enterprise/enterprise_callbacks/secrets_plugins/typeform_api_token.py,sh
|
|
|
99
99
|
litellm_enterprise/enterprise_callbacks/secrets_plugins/vault.py,sha256=fqtHTQTC6QaNMIZpuvntBnCSAgAhY2Ka-XOz4ZLafGk,653
|
|
100
100
|
litellm_enterprise/enterprise_callbacks/secrets_plugins/yandex.py,sha256=BVtFVzCTtpAkRJVudeZIEBBz1W8wueDzpu6TBvxngxo,1183
|
|
101
101
|
litellm_enterprise/enterprise_callbacks/secrets_plugins/zendesk_secret_key.py,sha256=3E21lWz12WUAmdnKDZH8znfTp6hRJbE3yImtfEP52qE,613
|
|
102
|
-
litellm_enterprise/enterprise_callbacks/send_emails/base_email.py,sha256=
|
|
102
|
+
litellm_enterprise/enterprise_callbacks/send_emails/base_email.py,sha256=qcY2oBDP9-30OTZF9bxPiXytgosUcGN5WGhKqSXTLE8,26083
|
|
103
103
|
litellm_enterprise/enterprise_callbacks/send_emails/endpoints.py,sha256=hOEpM_q8MJAXlKMOtC9KbgvDVr_YFtF3reu9bjXkpsI,7017
|
|
104
|
-
litellm_enterprise/enterprise_callbacks/send_emails/resend_email.py,sha256=
|
|
105
|
-
litellm_enterprise/enterprise_callbacks/send_emails/sendgrid_email.py,sha256=
|
|
106
|
-
litellm_enterprise/enterprise_callbacks/send_emails/smtp_email.py,sha256=
|
|
104
|
+
litellm_enterprise/enterprise_callbacks/send_emails/resend_email.py,sha256=KxNfvONZxSWbNg0HmWwfC0rvHzpN7MBJXAPKGLcy_tU,1541
|
|
105
|
+
litellm_enterprise/enterprise_callbacks/send_emails/sendgrid_email.py,sha256=4bvSOfV-WzCGIJX2V32Ug91I8GBQAmypDDp40qsZbQU,2318
|
|
106
|
+
litellm_enterprise/enterprise_callbacks/send_emails/smtp_email.py,sha256=CGXmT-7EwftreMQXqUL7OC-XSh0cOje4s16Ptt9wBxc,1245
|
|
107
107
|
litellm_enterprise/integrations/custom_guardrail.py,sha256=ZLVpqUZq9bR0vEFqVrlTJk0bYCZuFsXlw9XsdyK9t2E,1555
|
|
108
108
|
litellm_enterprise/litellm_core_utils/litellm_logging.py,sha256=BKkQLPqebFbN-KeCbipGIPgdxHEfQkczImdhhzxKoFg,868
|
|
109
109
|
litellm_enterprise/proxy/audit_logging_endpoints.py,sha256=BnHczmi4bnW1GpMNsq4CvnbwL3rgQ-pnrtFd5WBbbHY,5304
|
|
@@ -113,7 +113,7 @@ litellm_enterprise/proxy/auth/route_checks.py,sha256=FbXwbrOkFr1dODH6XxoIpLG1nKo
|
|
|
113
113
|
litellm_enterprise/proxy/auth/user_api_key_auth.py,sha256=7t5Q-JoKFyoymylaOT8KWAAOFVz0JOTl7PPOmTkpj5c,1144
|
|
114
114
|
litellm_enterprise/proxy/common_utils/check_batch_cost.py,sha256=V0CCHtN-JV-_d-ydXV-cVs3zCImt1699JnICGF3oPOk,7360
|
|
115
115
|
litellm_enterprise/proxy/enterprise_routes.py,sha256=ToJVSSNaYUotzgIg-kWsfsqh2E0GnQirOPkpE4YkHNg,907
|
|
116
|
-
litellm_enterprise/proxy/hooks/managed_files.py,sha256=
|
|
116
|
+
litellm_enterprise/proxy/hooks/managed_files.py,sha256=214MTFrwYs3yrAW84SNp-K8zYBZ9Ck2nkysKnLZjLAQ,42484
|
|
117
117
|
litellm_enterprise/proxy/management_endpoints/__init__.py,sha256=zfaqryxzmFu6se-w4yR2nlHKxDOOtHAWEehA2xFbFNg,270
|
|
118
118
|
litellm_enterprise/proxy/management_endpoints/internal_user_endpoints.py,sha256=GEoOVujrtKXDHfko2KQaLn-ms64zkutFE9PP5IhBBLM,2175
|
|
119
119
|
litellm_enterprise/proxy/management_endpoints/key_management_endpoints.py,sha256=-IXRzVrNQ3_krL-gxngelYQftwyPlB_HmgI3RN-HdvM,1147
|
|
@@ -121,10 +121,10 @@ litellm_enterprise/proxy/proxy_server.py,sha256=fzOeTyiyevLWi2767-2W1Co7reR-0wno
|
|
|
121
121
|
litellm_enterprise/proxy/readme.md,sha256=ZcigMJYSHWs4SWnYriWjrSVDJKsu44c2HsbYbma0EHU,397
|
|
122
122
|
litellm_enterprise/proxy/utils.py,sha256=y4ADfhlEG_mH0x5rfIg7D9FjS586lVgQ9DL0tTdgrMQ,962
|
|
123
123
|
litellm_enterprise/proxy/vector_stores/endpoints.py,sha256=6Guh6zIH00dh2XXStn6GblTGpGyE4hZJ9WThVZggDQg,12944
|
|
124
|
-
litellm_enterprise/types/enterprise_callbacks/send_emails.py,sha256=
|
|
124
|
+
litellm_enterprise/types/enterprise_callbacks/send_emails.py,sha256=AouBXqb1EB1-Mg3fM_3UjUDihIA45zIjRgA6M4vQ7Zw,2150
|
|
125
125
|
litellm_enterprise/types/proxy/audit_logging_endpoints.py,sha256=oSJVAuRD9r6ZjRCqNBFM-J5HSgOltsXts400b2aynRE,894
|
|
126
126
|
litellm_enterprise/types/proxy/proxy_server.py,sha256=kdhtxsU2uok6-XO_ebugCv7PzYYmGgv4vh-XemHJnpM,146
|
|
127
|
-
litellm_enterprise-0.1.
|
|
128
|
-
litellm_enterprise-0.1.
|
|
129
|
-
litellm_enterprise-0.1.
|
|
130
|
-
litellm_enterprise-0.1.
|
|
127
|
+
litellm_enterprise-0.1.27.dist-info/METADATA,sha256=NOumHWDgf-L-MMKoA9gCgEweLGji6O8_DMpB-Wa7TIE,1441
|
|
128
|
+
litellm_enterprise-0.1.27.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
129
|
+
litellm_enterprise-0.1.27.dist-info/licenses/LICENSE.md,sha256=nq3D9ZqOvRDT6hLkypQFTc3XsE15kbkg5rkkLJVSqKY,2251
|
|
130
|
+
litellm_enterprise-0.1.27.dist-info/RECORD,,
|
|
File without changes
|
{litellm_enterprise-0.1.25.dist-info → litellm_enterprise-0.1.27.dist-info}/licenses/LICENSE.md
RENAMED
|
File without changes
|