Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .semgrep/rules/python/reliability/naive-datetime.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Aware-vs-naive datetime comparisons raise TypeError at runtime.
# ISO-8601 input may carry "Z"/offset (tz-aware) or not (naive), so raw
# datetime.fromisoformat results must be normalized before comparing.
# parse_utc_datetime in litellm/litellm_core_utils/datetime_utils.py is the
# single allowed parse entrypoint under litellm/proxy/.

rules:
- id: ban-raw-fromisoformat-in-proxy
message: >-
Raw datetime.fromisoformat() is banned under litellm/proxy/. Use
parse_utc_datetime() from litellm.litellm_core_utils.datetime_utils, which
normalizes naive values to UTC so comparisons against
datetime.now(timezone.utc) cannot raise TypeError.
severity: ERROR
languages: [python]
paths:
include:
- litellm/proxy
pattern-either:
- pattern: datetime.fromisoformat(...)
- pattern: datetime.datetime.fromisoformat(...)
metadata:
category: reliability
cwe: "CWE-704: Incorrect Type Conversion or Cast"
tags: [python, reliability, datetime]
confidence: HIGH

- id: tz-unnormalized-fromisoformat-compare
message: >-
Comparing a datetime.fromisoformat(...) result without timezone
normalization raises TypeError when one side is tz-aware and the other is
naive. Use parse_utc_datetime() from
litellm.litellm_core_utils.datetime_utils and compare against
datetime.now(timezone.utc).
severity: ERROR
languages: [python]
mode: taint
pattern-sources:
- pattern: datetime.fromisoformat(...)
- pattern: datetime.datetime.fromisoformat(...)
pattern-sanitizers:
- pattern: $DT.replace(tzinfo=$TZ)
- by-side-effect: true
patterns:
- pattern: $DT
- pattern-inside: |
if <... $DT.tzinfo is None ...>:
$DT = $DT.replace(tzinfo=$TZ)
...
pattern-sinks:
- patterns:
- pattern-either:
- pattern: $X > $Y
- pattern: $X < $Y
- pattern: $X >= $Y
- pattern: $X <= $Y
- pattern: $X - $Y
metadata:
category: reliability
cwe: "CWE-704: Incorrect Type Conversion or Cast"
tags: [python, reliability, datetime]
confidence: MEDIUM
33 changes: 33 additions & 0 deletions litellm/litellm_core_utils/datetime_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""
Timezone-safe datetime parsing.

ISO-8601 strings arriving from API input, DB metadata, or serialized state may carry a
timezone offset ("...Z" / "+00:00") or not. Comparing an aware datetime with a naive one
raises TypeError, so every parse site must normalize. This module is the single allowed
entrypoint; a semgrep rule bans raw datetime.fromisoformat under litellm/proxy/.
"""

from datetime import datetime, timezone


def parse_utc_datetime(value: str | datetime) -> datetime:
"""Parse an ISO-8601 string (or pass through a datetime) into a tz-aware datetime.

Naive values are assumed to be UTC, matching the convention used across the proxy
(key expiry checks, budget windows, spend reports). The "Z" suffix is handled
explicitly because datetime.fromisoformat only accepts it from Python 3.11 and the
project floor is 3.10.

Raises ValueError for unparseable strings and TypeError for any other type,
mirroring datetime.fromisoformat's own contract so existing
``except (ValueError, TypeError)`` handlers stay fail-closed.
"""
if isinstance(value, str):
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
elif isinstance(value, datetime):
parsed = value
else:
raise TypeError(f"parse_utc_datetime expects str or datetime, got {type(value).__name__}")
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.datetime_utils import parse_utc_datetime
from litellm.proxy._experimental.mcp_server.oauth_utils import (
get_request_base_url,
well_known_root_suffix,
Expand Down Expand Up @@ -867,9 +868,7 @@ def _admitted_key_is_active(key_object: UserAPIKeyAuth) -> bool:
expires = key_object.expires
if expires is None:
return True
expiry = expires if isinstance(expires, datetime) else datetime.fromisoformat(expires)
if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None:
expiry = expiry.replace(tzinfo=timezone.utc)
expiry = parse_utc_datetime(expires)
return expiry >= datetime.now(timezone.utc)

@staticmethod
Expand Down
16 changes: 6 additions & 10 deletions litellm/proxy/_experimental/mcp_server/bridge_token_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing_extensions import assert_never

from litellm._logging import verbose_logger
from litellm.litellm_core_utils.datetime_utils import parse_utc_datetime
from litellm.proxy._experimental.mcp_server.oauth_utils import TOKEN_NO_CACHE_HEADERS
from litellm.types.mcp_server.mcp_server_manager import MCPServer

Expand Down Expand Up @@ -62,23 +63,18 @@ def _key_is_active(key_obj: "UserAPIKeyAuth") -> bool:
store) derive it separately via :func:`_active_key_user_id`.

Total by design: ``expires`` is typed ``str | datetime``, and an unparseable string would make
``datetime.fromisoformat`` raise. Since the callers run this outside their key-resolution
``parse_utc_datetime`` raise. Since the callers run this outside their key-resolution
``try``, an uncaught parse error would surface as a 500 instead of the endpoint's fail-closed
behavior, so a malformed expiry is treated as inactive (return ``False``) rather than raising.
"""
if key_obj.blocked is True:
return False
expires = key_obj.expires
if expires is not None:
if isinstance(expires, datetime):
expiry = expires
else:
try:
expiry = datetime.fromisoformat(expires)
except (ValueError, TypeError):
return False
if expiry.tzinfo is None or expiry.tzinfo.utcoffset(expiry) is None:
expiry = expiry.replace(tzinfo=timezone.utc)
try:
expiry = parse_utc_datetime(expires)
except (ValueError, TypeError):
return False
if expiry < datetime.now(timezone.utc):
return False
return True
Expand Down
9 changes: 3 additions & 6 deletions litellm/proxy/_experimental/mcp_server/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.constants import MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS
from litellm.litellm_core_utils.datetime_utils import parse_utc_datetime
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import (
build_token_endpoint_client_auth,
Expand Down Expand Up @@ -1192,9 +1193,7 @@ def is_oauth_credential_expired(cred: Dict[str, Any], buffer_seconds: int = 0) -
if not expires_at:
return False
try:
exp_dt = datetime.fromisoformat(expires_at)
if exp_dt.tzinfo is None:
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
exp_dt = parse_utc_datetime(expires_at)
return datetime.now(timezone.utc) + timedelta(seconds=buffer_seconds) > exp_dt
except (ValueError, TypeError):
return False
Expand Down Expand Up @@ -1551,11 +1550,9 @@ def _remaining_token_seconds(expires_at: str | None) -> int | None:
if not expires_at:
return None
try:
exp_dt = datetime.fromisoformat(expires_at)
exp_dt = parse_utc_datetime(expires_at)
except (ValueError, TypeError):
return None
if exp_dt.tzinfo is None:
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
remaining = int((exp_dt - datetime.now(timezone.utc)).total_seconds())
return remaining if remaining > 0 else None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
from __future__ import annotations

from collections.abc import Awaitable, Callable
from datetime import datetime, timezone

from litellm.litellm_core_utils.datetime_utils import parse_utc_datetime
from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import (
OAuthToken,
)
Expand All @@ -22,15 +22,9 @@

def _iso_to_epoch(expires_at: str) -> float | None:
try:
dt = datetime.fromisoformat(expires_at)
return parse_utc_datetime(expires_at).timestamp()
except ValueError:
return None
# A timezone-naive expiry is stored as UTC (db.py writes ``datetime.now(timezone.utc)``),
# so anchor it to UTC before ``.timestamp()`` - otherwise a non-UTC host would read it as
# local time and skew the expiry, diverging from v1's ``_remaining_token_seconds``.
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()


def _to_scopes(raw: object) -> tuple[str, ...]:
Expand Down
26 changes: 5 additions & 21 deletions litellm/proxy/auth/user_api_key_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.integrations.otel.runtime import phase_span, seed_request_identity
from litellm.litellm_core_utils.datetime_utils import parse_utc_datetime
from litellm.litellm_core_utils.dd_tracing import tracer
from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value
from litellm.proxy._types import *
Expand Down Expand Up @@ -1515,12 +1516,7 @@ async def _user_api_key_auth_builder(
):
if valid_token.expires is not None:
current_time = datetime.now(timezone.utc)
if isinstance(valid_token.expires, datetime):
expiry_time = valid_token.expires
else:
expiry_time = datetime.fromisoformat(valid_token.expires)
if expiry_time.tzinfo is None or expiry_time.tzinfo.utcoffset(expiry_time) is None:
expiry_time = expiry_time.replace(tzinfo=timezone.utc)
expiry_time = parse_utc_datetime(valid_token.expires)
if expiry_time < current_time:
await _delete_cache_key_object(
hashed_token=hash_token(api_key),
Expand Down Expand Up @@ -1804,12 +1800,7 @@ async def _user_api_key_auth_builder(
# Check 3. If token is expired
if valid_token.expires is not None:
current_time = datetime.now(timezone.utc)
if isinstance(valid_token.expires, datetime):
expiry_time = valid_token.expires
else:
expiry_time = datetime.fromisoformat(valid_token.expires)
if expiry_time.tzinfo is None or expiry_time.tzinfo.utcoffset(expiry_time) is None:
expiry_time = expiry_time.replace(tzinfo=timezone.utc)
expiry_time = parse_utc_datetime(valid_token.expires)
verbose_proxy_logger.debug(
f"Checking if token expired, expiry time {expiry_time} and current time {current_time}"
)
Expand Down Expand Up @@ -2683,9 +2674,7 @@ def get_api_key_from_custom_header(request: Request, custom_litellm_key_header_n
def _get_temp_budget_increase(valid_token: UserAPIKeyAuth):
valid_token_metadata = valid_token.metadata
if "temp_budget_increase" in valid_token_metadata and "temp_budget_expiry" in valid_token_metadata:
expiry = datetime.fromisoformat(valid_token_metadata["temp_budget_expiry"])
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
expiry = parse_utc_datetime(valid_token_metadata["temp_budget_expiry"])
if expiry > datetime.now(timezone.utc):
return valid_token_metadata["temp_budget_increase"]
return None
Expand Down Expand Up @@ -2894,12 +2883,7 @@ async def _run_post_custom_auth_checks(
# 2. Check token expiry
if valid_token.expires is not None:
current_time = datetime.now(timezone.utc)
if isinstance(valid_token.expires, datetime):
expiry_time = valid_token.expires
else:
expiry_time = datetime.fromisoformat(valid_token.expires)
if expiry_time.tzinfo is None or expiry_time.tzinfo.utcoffset(expiry_time) is None:
expiry_time = expiry_time.replace(tzinfo=timezone.utc)
expiry_time = parse_utc_datetime(valid_token.expires)
if expiry_time < current_time:
raise ProxyException(
message=f"Authentication Error - Expired Key. Key Expiry time {expiry_time} and current time {current_time}",
Expand Down
18 changes: 6 additions & 12 deletions litellm/proxy/client/cli/commands/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import requests
from rich.table import Table

from litellm.litellm_core_utils.datetime_utils import parse_utc_datetime

from ...keys import KeysManagementClient


Expand Down Expand Up @@ -224,22 +226,14 @@ def _filter_keys_by_created_since(
if not created_since_dt:
return source_keys

created_since_utc = parse_utc_datetime(created_since_dt)
filtered_keys = []
for key in source_keys:
key_created_at = key.get("created_at")
if key_created_at:
# Parse the key's created_at timestamp
if isinstance(key_created_at, str):
if "T" in key_created_at:
key_dt = datetime.fromisoformat(key_created_at.replace("Z", "+00:00"))
else:
key_dt = datetime.fromisoformat(key_created_at)

# Convert to naive datetime for comparison (assuming UTC)
if key_dt.tzinfo:
key_dt = key_dt.replace(tzinfo=None)

if key_dt >= created_since_dt:
key_dt = parse_utc_datetime(key_created_at)
if key_dt >= created_since_utc:
filtered_keys.append(key)

click.echo(f"Filtered {len(source_keys)} keys to {len(filtered_keys)} keys created since {created_since}")
Expand All @@ -262,7 +256,7 @@ def _display_dry_run_table(source_keys: List[Dict[str, Any]]) -> None:
if isinstance(created_at, str):
# Handle common timestamp formats
if "T" in created_at:
dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
dt = parse_utc_datetime(created_at)
created_at = dt.strftime("%Y-%m-%d %H:%M")

table.add_row(str(key.get("key_alias", "")), str(key.get("user_id", "")), str(created_at))
Expand Down
5 changes: 3 additions & 2 deletions litellm/proxy/client/cli/commands/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import rich

# local imports
from litellm.litellm_core_utils.datetime_utils import parse_utc_datetime

from ... import Client


Expand Down Expand Up @@ -51,8 +53,7 @@ def format_iso_datetime_str(iso_datetime_str: Optional[str]) -> str:
if not iso_datetime_str:
return ""
try:
# Parse ISO format datetime string
dt = datetime.fromisoformat(iso_datetime_str.replace("Z", "+00:00"))
dt = parse_utc_datetime(iso_datetime_str)
return dt.strftime("%Y-%m-%d %H:%M")
except (TypeError, ValueError):
return str(iso_datetime_str)
Expand Down
4 changes: 2 additions & 2 deletions litellm/proxy/common_utils/reset_budget_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import litellm
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.datetime_utils import parse_utc_datetime
from litellm.proxy._types import (
LiteLLM_BudgetTableFull,
LiteLLM_EndUserTable,
Expand Down Expand Up @@ -684,8 +685,7 @@ async def _reset_expired_window(
reset_at_str = window.get("reset_at")
if not reset_at_str:
return False
reset_at = datetime.fromisoformat(reset_at_str.replace("Z", "+00:00")).replace(tzinfo=None)
if reset_at > now:
if parse_utc_datetime(reset_at_str) > parse_utc_datetime(now):
return False
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.0)
if spend_counter_cache.redis_cache is not None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import List, Optional, Tuple

from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.datetime_utils import parse_utc_datetime
from litellm.constants import (
SPEND_LOG_PARTITION_INTERVAL,
SPEND_LOG_PARTITION_PRECREATE_AHEAD,
Expand Down Expand Up @@ -86,18 +87,20 @@ def parse_partition_upper_bound(bound_expr: str) -> Optional[datetime]:
if match is None:
return None
try:
return datetime.fromisoformat(match.group(1))
return parse_utc_datetime(match.group(1))
except ValueError:
return None


def select_partitions_to_drop(partitions: List[Tuple[str, Optional[datetime]]], cutoff: datetime) -> List[str]:
"""
Names of partitions whose entire range is older than `cutoff` (upper bound
<= cutoff). `cutoff` and the bounds are UTC-naive. Partitions without a
parseable upper bound (e.g. DEFAULT) are kept.
<= cutoff). `cutoff` and the bounds are normalized to tz-aware UTC before
comparing (naive values are assumed UTC). Partitions without a parseable
upper bound (e.g. DEFAULT) are kept.
"""
return [name for name, upper in partitions if upper is not None and upper <= cutoff]
aware_cutoff = parse_utc_datetime(cutoff)
return [name for name, upper in partitions if upper is not None and upper <= aware_cutoff]


class SpendLogsPartitionManager:
Expand Down Expand Up @@ -179,9 +182,8 @@ async def _list_partitions(self, prisma_client) -> List[Tuple[str, Optional[date

async def drop_partitions_older_than(self, prisma_client, cutoff: datetime) -> List[str]:
"""DROP every partition whose whole range is older than `cutoff`."""
cutoff_naive = cutoff.astimezone(timezone.utc).replace(tzinfo=None)
partitions = await self._list_partitions(prisma_client)
to_drop = select_partitions_to_drop(partitions, cutoff_naive)
to_drop = select_partitions_to_drop(partitions, cutoff)
dropped: List[str] = []
for name in to_drop:
try:
Expand Down
Loading
Loading