1
+ import re
1
2
from datetime import datetime , UTC , timedelta
2
3
from sqlalchemy .orm import Session
3
4
from app .db .models import DBTeam , DBProduct , DBTeamProduct , DBPrivateAIKey , DBUser , DBRegion
17
18
INVOICE_SUCCESS_EVENTS
18
19
)
19
20
from prometheus_client import Gauge , Counter , Summary
20
- from typing import Dict , List
21
+ from typing import Dict , List , Optional
21
22
from app .core .security import create_access_token
22
23
from app .core .config import settings
23
24
from urllib .parse import urljoin
@@ -95,6 +96,8 @@ def get_team_keys_by_region(db: Session, team_id: int) -> Dict[DBRegion, List[DB
95
96
logger .info (f"Found { len (team_keys )} keys in { len (keys_by_region )} regions for team { team_id } " )
96
97
return keys_by_region
97
98
99
+
100
+
98
101
async def handle_stripe_event_background (event , db : Session ):
99
102
"""
100
103
Background task to handle Stripe webhook events.
@@ -247,19 +250,28 @@ async def remove_product_from_team(db: Session, customer_id: str, product_id: st
247
250
logger .error (f"Error removing product from team: { str (e )} " )
248
251
raise e
249
252
250
- async def monitor_team_keys (team : DBTeam , keys_by_region : Dict [DBRegion , List [DBPrivateAIKey ]], expire_keys : bool ) -> float :
253
+ async def monitor_team_keys (
254
+ team : DBTeam ,
255
+ keys_by_region : Dict [DBRegion , List [DBPrivateAIKey ]],
256
+ expire_keys : bool ,
257
+ renewal_period_days : Optional [int ] = None ,
258
+ max_budget_amount : Optional [float ] = None
259
+ ) -> float :
251
260
"""
252
- Monitor spend for all keys in a team across different regions.
261
+ Monitor spend for all keys in a team across different regions and optionally update keys after renewal period .
253
262
254
263
Args:
255
- db: Database session
256
264
team: The team to monitor keys for
257
265
keys_by_region: Dictionary mapping regions to lists of keys
266
+ expire_keys: Whether to expire keys (set duration to 0)
267
+ renewal_period_days: Optional renewal period in days. If provided, will check for and update keys renewed within the last hour.
268
+ max_budget_amount: Optional maximum budget amount. If provided, will update the budget amount for the keys.
258
269
259
270
Returns:
260
271
float: Total spend across all keys for the team
261
272
"""
262
273
team_total = 0
274
+ current_time = datetime .now (UTC )
263
275
264
276
# Monitor keys for each region
265
277
for region , keys in keys_by_region .items ():
@@ -281,6 +293,86 @@ async def monitor_team_keys(team: DBTeam, keys_by_region: Dict[DBRegion, List[DB
281
293
budget = info .get ("max_budget" , 0 ) or 0.0
282
294
key_alias = info .get ("key_alias" , f"key-{ key .id } " ) # Fallback to key-{id} if no alias
283
295
296
+ # Check for renewal period update if renewal_period_days is provided
297
+ if renewal_period_days is not None :
298
+ # Check current values and only update if they don't match the parameters
299
+ current_budget_duration = info .get ("budget_duration" )
300
+ current_max_budget = info .get ("max_budget" )
301
+
302
+ needs_update = False
303
+ data = {"litellm_token" : key .litellm_token }
304
+
305
+ # Check if budget_duration needs updating
306
+ expected_budget_duration = f"{ renewal_period_days } d"
307
+ if current_budget_duration != expected_budget_duration :
308
+ data ["budget_duration" ] = expected_budget_duration
309
+ needs_update = True
310
+ logger .info (f"Key { key .id } budget_duration will be updated from '{ current_budget_duration } ' to '{ expected_budget_duration } '" )
311
+
312
+ # Check if budget_amount needs updating
313
+ if max_budget_amount is not None and current_max_budget != max_budget_amount :
314
+ data ["budget_amount" ] = max_budget_amount
315
+ needs_update = True
316
+ logger .info (f"Key { key .id } budget_amount will be updated from { current_max_budget } to { max_budget_amount } " )
317
+
318
+ # Only check reset timestamp if we need to update
319
+ if needs_update :
320
+ budget_reset_at_str = info .get ("budget_reset_at" )
321
+ if budget_reset_at_str :
322
+ try :
323
+ # Parse the budget_reset_at timestamp
324
+ budget_reset_at = datetime .fromisoformat (budget_reset_at_str .replace ('Z' , '+00:00' ))
325
+ if budget_reset_at .tzinfo is None :
326
+ budget_reset_at = budget_reset_at .replace (tzinfo = UTC )
327
+ logger .info (f"Key { key .id } budget_reset_at_str: { budget_reset_at_str } , budget_reset_at: { budget_reset_at } " )
328
+
329
+ # Check if budget was reset recently using heuristics
330
+ # budget_reset_at represents when the next reset will occur
331
+ current_spend = info .get ("spend" , 0 ) or 0.0
332
+ current_budget_duration = info .get ("budget_duration" )
333
+
334
+ should_update = False
335
+ update_reason = ""
336
+
337
+ # Heuristic 1: Check if (now + current_budget_duration) is within an hour of budget_reset_at
338
+ if current_budget_duration is not None :
339
+ try :
340
+ # Parse current budget duration (e.g., "30d" -> 30 days)
341
+ duration_match = re .match (r'(\d+)d' , current_budget_duration )
342
+ if duration_match :
343
+ duration_days = int (duration_match .group (1 ))
344
+ expected_reset_time = current_time + timedelta (days = duration_days )
345
+ hours_diff = abs ((expected_reset_time - budget_reset_at ).total_seconds () / 3600 )
346
+
347
+ if hours_diff <= 1.0 :
348
+ should_update = True
349
+ update_reason = f"reset time alignment (within { hours_diff :.2f} hours)"
350
+ except (ValueError , AttributeError ):
351
+ logger .warning (f"Key { key .id } has invalid budget_duration format: { current_budget_duration } " )
352
+ else :
353
+ logger .debug (f"Key { key .id } has no budget_duration set, skipping reset time alignment heuristic" )
354
+ should_update = True
355
+ update_reason = "no budget_duration set, forcing update"
356
+
357
+ # Heuristic 2: Update if amount spent is $0.00 (indicating fresh reset)
358
+ if current_spend == 0.0 :
359
+ should_update = True
360
+ update_reason = "zero spend (fresh reset)"
361
+
362
+ if should_update :
363
+ logger .info (f"Key { key .id } budget update triggered: { update_reason } , updating budget settings" )
364
+ await litellm_service .update_budget (** data )
365
+ logger .info (f"Updated key { key .id } budget settings" )
366
+ else :
367
+ logger .debug (f"Key { key .id } budget update not triggered, skipping update" )
368
+ except ValueError :
369
+ logger .warning (f"Key { key .id } has invalid budget_reset_at timestamp: { budget_reset_at_str } " )
370
+ else :
371
+ logger .warning (f"Key { key .id } has no budget_reset_at timestamp, forcing update" )
372
+ await litellm_service .update_budget (** data )
373
+ else :
374
+ logger .info (f"Key { key .id } budget settings already match the expected values, no update needed" )
375
+
284
376
# Set the key duration to 0 days to end its usability.
285
377
if expire_keys :
286
378
await litellm_service .update_key_duration (key .litellm_token , "0d" )
@@ -332,15 +424,18 @@ async def monitor_teams(db: Session):
332
424
"""
333
425
logger .info ("Monitoring teams" )
334
426
try :
335
- # Initialize SES service
336
- ses_service = SESService ()
337
-
338
427
# Get all teams
339
428
teams = db .query (DBTeam ).all ()
340
429
current_time = datetime .now (UTC )
341
430
342
431
# Track current active team labels
343
432
current_team_labels = set ()
433
+ try :
434
+ # Initialize SES service
435
+ ses_service = SESService ()
436
+ except Exception as e :
437
+ logger .error (f"Error initializing SES service: { str (e )} " )
438
+ pass
344
439
345
440
logger .info (f"Found { len (teams )} teams to track" )
346
441
for team in teams :
@@ -436,8 +531,24 @@ async def monitor_teams(db: Session):
436
531
if not has_products and days_remaining <= 0 and should_send_notifications :
437
532
expire_keys = True
438
533
439
- # Monitor keys and get total spend
440
- team_total = await monitor_team_keys (team , keys_by_region , expire_keys )
534
+ # Determine if we should check for renewal period updates
535
+ renewal_period_days = None
536
+ max_budget_amount = None
537
+ if has_products and team .last_payment :
538
+ # Get the product with the longest renewal period
539
+ active_products = db .query (DBTeamProduct ).filter (
540
+ DBTeamProduct .team_id == team .id
541
+ ).all ()
542
+ product_ids = [tp .product_id for tp in active_products ]
543
+ products = db .query (DBProduct ).filter (DBProduct .id .in_ (product_ids )).all ()
544
+
545
+ if products :
546
+ max_renewal_product = max (products , key = lambda product : product .renewal_period_days )
547
+ renewal_period_days = max_renewal_product .renewal_period_days
548
+ max_budget_amount = max (products , key = lambda product : product .max_budget_per_key ).max_budget_per_key
549
+
550
+ # Monitor keys and get total spend (includes renewal period updates if applicable)
551
+ team_total = await monitor_team_keys (team , keys_by_region , expire_keys , renewal_period_days , max_budget_amount )
441
552
442
553
# Set the total spend metric for the team (always emit metrics)
443
554
team_total_spend .labels (
0 commit comments