diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4fa58e9d244..4ea6f0d2446 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11,7 +11,7 @@ import time import traceback import warnings -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import ( TYPE_CHECKING, Any, @@ -8042,6 +8042,48 @@ async def _apply_search_filter_to_models( return filtered_models, search_total_count +def _normalize_datetime_for_sorting(dt: Any) -> Optional[datetime]: + """ + Normalize a datetime value to a timezone-aware UTC datetime for sorting. + + This function handles: + - None values: returns None + - String values: parses ISO format strings and converts to UTC-aware datetime + - Datetime objects: converts naive datetimes to UTC-aware, and aware datetimes to UTC + + Args: + dt: Datetime value (None, str, or datetime object) + + Returns: + UTC-aware datetime object, or None if input is None or cannot be parsed + """ + if dt is None: + return None + + if isinstance(dt, str): + try: + # Handle ISO format strings, including 'Z' suffix + dt_str = dt.replace("Z", "+00:00") if dt.endswith("Z") else dt + parsed_dt = datetime.fromisoformat(dt_str) + # Ensure it's UTC-aware + if parsed_dt.tzinfo is None: + parsed_dt = parsed_dt.replace(tzinfo=timezone.utc) + else: + parsed_dt = parsed_dt.astimezone(timezone.utc) + return parsed_dt + except (ValueError, AttributeError): + return None + + if isinstance(dt, datetime): + # If naive, assume UTC and make it aware + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + # If aware, convert to UTC + return dt.astimezone(timezone.utc) + + return None + + def _sort_models( all_models: List[Dict[str, Any]], sort_by: Optional[str], @@ -8071,26 +8113,18 @@ def get_sort_key(model: Dict[str, Any]) -> Any: elif sort_by == "created_at": created_at = model_info.get("created_at") - if created_at is None: + normalized_dt = _normalize_datetime_for_sorting(created_at) + if normalized_dt is None: # Put None values at the end for asc, at the start for desc - return (datetime.max if not reverse else datetime.min) - if isinstance(created_at, str): - try: - return datetime.fromisoformat(created_at.replace("Z", "+00:00")) - except (ValueError, AttributeError): - return datetime.min if not reverse else datetime.max - return created_at + return (datetime.max.replace(tzinfo=timezone.utc) if not reverse else datetime.min.replace(tzinfo=timezone.utc)) + return normalized_dt elif sort_by == "updated_at": updated_at = model_info.get("updated_at") - if updated_at is None: - return (datetime.max if not reverse else datetime.min) - if isinstance(updated_at, str): - try: - return datetime.fromisoformat(updated_at.replace("Z", "+00:00")) - except (ValueError, AttributeError): - return datetime.min if not reverse else datetime.max - return updated_at + normalized_dt = _normalize_datetime_for_sorting(updated_at) + if normalized_dt is None: + return (datetime.max.replace(tzinfo=timezone.utc) if not reverse else datetime.min.replace(tzinfo=timezone.utc)) + return normalized_dt elif sort_by == "costs": input_cost = model_info.get("input_cost_per_token", 0) or 0 diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 9069a57643b..d85dbb2e0f9 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -5,7 +5,7 @@ import socket import subprocess import sys -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from unittest import mock from unittest.mock import AsyncMock, MagicMock, mock_open, patch @@ -1049,6 +1049,82 @@ async def test_get_config_from_file(tmp_path, monkeypatch): assert result == test_config +def test_normalize_datetime_for_sorting(): + """ + Test the _normalize_datetime_for_sorting function. + Tests various scenarios: None values, ISO format strings, datetime objects (naive and aware). + """ + from litellm.proxy.proxy_server import _normalize_datetime_for_sorting + + # Test Case 1: None value + assert _normalize_datetime_for_sorting(None) is None + + # Test Case 2: ISO format string with 'Z' suffix + dt_str_z = "2024-01-15T10:30:00Z" + result = _normalize_datetime_for_sorting(dt_str_z) + assert result is not None + assert isinstance(result, datetime) + assert result.tzinfo == timezone.utc + assert result.year == 2024 + assert result.month == 1 + assert result.day == 15 + assert result.hour == 10 + assert result.minute == 30 + + # Test Case 3: ISO format string without 'Z' suffix (naive) + dt_str_naive = "2024-01-15T10:30:00" + result = _normalize_datetime_for_sorting(dt_str_naive) + assert result is not None + assert isinstance(result, datetime) + assert result.tzinfo == timezone.utc + + # Test Case 4: ISO format string with timezone offset + dt_str_tz = "2024-01-15T10:30:00+05:00" + result = _normalize_datetime_for_sorting(dt_str_tz) + assert result is not None + assert isinstance(result, datetime) + assert result.tzinfo == timezone.utc + # Should convert from +05:00 to UTC (subtract 5 hours) + assert result.hour == 5 # 10:30 - 5 hours = 5:30 UTC + + # Test Case 5: Naive datetime object + naive_dt = datetime(2024, 1, 15, 10, 30, 0) + result = _normalize_datetime_for_sorting(naive_dt) + assert result is not None + assert isinstance(result, datetime) + assert result.tzinfo == timezone.utc + assert result.year == 2024 + assert result.month == 1 + assert result.day == 15 + + # Test Case 6: Timezone-aware datetime object (non-UTC) + from datetime import timedelta + aware_dt = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone(timedelta(hours=5))) + result = _normalize_datetime_for_sorting(aware_dt) + assert result is not None + assert isinstance(result, datetime) + assert result.tzinfo == timezone.utc + # Should convert from +05:00 to UTC + assert result.hour == 5 + + # Test Case 7: UTC-aware datetime object + utc_dt = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) + result = _normalize_datetime_for_sorting(utc_dt) + assert result is not None + assert isinstance(result, datetime) + assert result.tzinfo == timezone.utc + assert result == utc_dt + + # Test Case 8: Invalid string format + invalid_str = "not-a-date" + result = _normalize_datetime_for_sorting(invalid_str) + assert result is None + + # Test Case 9: Invalid type (should return None) + result = _normalize_datetime_for_sorting(12345) + assert result is None + + @pytest.mark.asyncio async def test_add_proxy_budget_to_db_only_creates_user_no_keys(): """