From 4c3ce93f7795902067c424fd4596418777ac3e97 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 18:47:53 +0000 Subject: [PATCH] Convert daily-aggregate `date` columns from String to DATE The eight daily-aggregate tables (LiteLLM_DailyUserSpend, LiteLLM_DailyOrganizationSpend, LiteLLM_DailyEndUserSpend, LiteLLM_DailyAgentSpend, LiteLLM_DailyTeamSpend, LiteLLM_DailyTagSpend, LiteLLM_DailyGuardrailMetrics, LiteLLM_DailyPolicyMetrics) all stored the per-row `date` field as TEXT in YYYY-MM-DD format. They were always intended to represent a date-of-day, but the column type made range queries dependent on lexical ordering and forced `::date` casts in custom SQL (which prevented index usage in spots). This change migrates the DB column to native PostgreSQL DATE (`DateTime @db.Date` in the schema) and routes Python writes/reads through small conversion helpers: * In-memory transactions still carry `date` as a YYYY-MM-DD string so the existing dict-based transaction key, Redis JSON serialization, and BaseDailySpendTransaction shape are untouched. * `to_db_date` converts the string to a tz-aware UTC datetime at the Prisma boundary (writes + where-clauses). * `to_date_str` converts a Prisma-returned `datetime` back to YYYY-MM-DD when used as a grouping key or wire response. * Raw SQL date filters cast string params with `$N::date` for unambiguous comparison. A guarded migration converts each existing TEXT column to DATE only if the column type still says `text`, so the migration is idempotent for fresh databases and re-runs. Tests for write boundary (`_update_daily_spend`) updated to expect a `datetime` in the Prisma payload; new tests cover the conversion helpers. Co-authored-by: Mateo Wang --- .../migration.sql | 98 +++++++++++++++++++ .../litellm_proxy_extras/schema.prisma | 16 +-- litellm/proxy/_types.py | 4 + .../proxy/db/daily_aggregate_date_utils.py | 66 +++++++++++++ litellm/proxy/db/db_spend_update_writer.py | 11 ++- litellm/proxy/guardrails/usage_endpoints.py | 25 +++-- litellm/proxy/guardrails/usage_tracking.py | 6 +- .../common_daily_activity.py | 23 +++-- .../user_agent_analytics_endpoints.py | 25 +++-- litellm/proxy/schema.prisma | 16 +-- .../common_daily_activity.py | 8 +- schema.prisma | 16 +-- .../db/test_daily_aggregate_date_utils.py | 67 +++++++++++++ .../proxy/db/test_db_spend_update_writer.py | 11 ++- 14 files changed, 333 insertions(+), 59 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260502182927_convert_daily_spend_date_to_date_type/migration.sql create mode 100644 litellm/proxy/db/daily_aggregate_date_utils.py create mode 100644 tests/test_litellm/proxy/db/test_daily_aggregate_date_utils.py diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260502182927_convert_daily_spend_date_to_date_type/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260502182927_convert_daily_spend_date_to_date_type/migration.sql new file mode 100644 index 00000000000..cf96107077b --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260502182927_convert_daily_spend_date_to_date_type/migration.sql @@ -0,0 +1,98 @@ +-- Convert `date` column on daily-aggregate spend / metrics tables from TEXT (YYYY-MM-DD) +-- to native PostgreSQL DATE. The text column has always been a date-of-day in YYYY-MM-DD +-- format, so the cast is lossless. This unlocks proper range queries / sorts / +-- date arithmetic without `::date` casting (which prevented index usage), and brings the +-- column type in line with what it actually represents. +-- +-- Notes: +-- - Each ALTER COLUMN is wrapped in a guard against repeated runs (column already DATE). +-- - All affected unique constraints / indexes implicitly retain their definitions; the +-- underlying column type changes but indexes are rebuilt by Postgres automatically. + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'LiteLLM_DailyUserSpend' AND column_name = 'date' AND data_type = 'text' + ) THEN + ALTER TABLE "LiteLLM_DailyUserSpend" + ALTER COLUMN "date" TYPE DATE USING "date"::date; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'LiteLLM_DailyOrganizationSpend' AND column_name = 'date' AND data_type = 'text' + ) THEN + ALTER TABLE "LiteLLM_DailyOrganizationSpend" + ALTER COLUMN "date" TYPE DATE USING "date"::date; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'LiteLLM_DailyEndUserSpend' AND column_name = 'date' AND data_type = 'text' + ) THEN + ALTER TABLE "LiteLLM_DailyEndUserSpend" + ALTER COLUMN "date" TYPE DATE USING "date"::date; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'LiteLLM_DailyAgentSpend' AND column_name = 'date' AND data_type = 'text' + ) THEN + ALTER TABLE "LiteLLM_DailyAgentSpend" + ALTER COLUMN "date" TYPE DATE USING "date"::date; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'LiteLLM_DailyTeamSpend' AND column_name = 'date' AND data_type = 'text' + ) THEN + ALTER TABLE "LiteLLM_DailyTeamSpend" + ALTER COLUMN "date" TYPE DATE USING "date"::date; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'LiteLLM_DailyTagSpend' AND column_name = 'date' AND data_type = 'text' + ) THEN + ALTER TABLE "LiteLLM_DailyTagSpend" + ALTER COLUMN "date" TYPE DATE USING "date"::date; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'LiteLLM_DailyGuardrailMetrics' AND column_name = 'date' AND data_type = 'text' + ) THEN + ALTER TABLE "LiteLLM_DailyGuardrailMetrics" + ALTER COLUMN "date" TYPE DATE USING "date"::date; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'LiteLLM_DailyPolicyMetrics' AND column_name = 'date' AND data_type = 'text' + ) THEN + ALTER TABLE "LiteLLM_DailyPolicyMetrics" + ALTER COLUMN "date" TYPE DATE USING "date"::date; + END IF; +END $$; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index a9d3911c07b..86f045a38ac 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -679,7 +679,7 @@ model LiteLLM_AuditLog { model LiteLLM_DailyUserSpend { id String @id @default(uuid()) user_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -710,7 +710,7 @@ model LiteLLM_DailyUserSpend { model LiteLLM_DailyOrganizationSpend { id String @id @default(uuid()) organization_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -741,7 +741,7 @@ model LiteLLM_DailyOrganizationSpend { model LiteLLM_DailyEndUserSpend { id String @id @default(uuid()) end_user_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -771,7 +771,7 @@ model LiteLLM_DailyEndUserSpend { model LiteLLM_DailyAgentSpend { id String @id @default(uuid()) agent_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -801,7 +801,7 @@ model LiteLLM_DailyAgentSpend { model LiteLLM_DailyTeamSpend { id String @id @default(uuid()) team_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -833,7 +833,7 @@ model LiteLLM_DailyTagSpend { id String @id @default(uuid()) request_id String? tag String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -962,7 +962,7 @@ model LiteLLM_GuardrailsTable { // Daily guardrail metrics for usage dashboard (one row per guardrail per day) model LiteLLM_DailyGuardrailMetrics { guardrail_id String // logical id; may not FK if guardrail from config - date String // YYYY-MM-DD + date DateTime @db.Date requests_evaluated BigInt @default(0) passed_count BigInt @default(0) blocked_count BigInt @default(0) @@ -980,7 +980,7 @@ model LiteLLM_DailyGuardrailMetrics { // Daily policy metrics for usage dashboard (one row per policy per day) model LiteLLM_DailyPolicyMetrics { policy_id String - date String // YYYY-MM-DD + date DateTime @db.Date requests_evaluated BigInt @default(0) passed_count BigInt @default(0) blocked_count BigInt @default(0) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 3cca23f07ab..9813747e4e0 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4518,6 +4518,10 @@ class DefaultInternalUserParams(LiteLLMPydanticObjectBase): class BaseDailySpendTransaction(TypedDict): + # `date` is the in-memory representation, kept as a YYYY-MM-DD string so it + # serializes through Redis cleanly and remains a stable transaction-key + # component. The corresponding DB column is `DateTime @db.Date`; conversion + # happens at the Prisma boundary (see `to_db_date`). date: str api_key: str model: Optional[str] diff --git a/litellm/proxy/db/daily_aggregate_date_utils.py b/litellm/proxy/db/daily_aggregate_date_utils.py new file mode 100644 index 00000000000..606bedfe60e --- /dev/null +++ b/litellm/proxy/db/daily_aggregate_date_utils.py @@ -0,0 +1,66 @@ +""" +Helpers for converting between in-memory `date` strings (`YYYY-MM-DD`) used by +the daily-aggregate spend pipeline and the native `DateTime @db.Date` column +that Prisma now persists. + +The in-memory / Redis representation is intentionally kept as a string so +existing transaction keys, JSON serialization in Redis, and existing typed +dict shapes (`BaseDailySpendTransaction.date: str`) continue to work without +changing the wire format. Conversion happens at the Prisma boundary only. +""" + +from datetime import date, datetime, timezone +from typing import Optional, Union + +DateLike = Union[str, date, datetime] + + +def to_db_date(value: Optional[DateLike]) -> Optional[datetime]: + """Normalize a date-like value to a UTC-midnight ``datetime``. + + prisma-client-py serializes Python ``datetime`` instances when writing to + a ``DateTime @db.Date`` column. Postgres then truncates to the date + portion. We always emit a tz-aware UTC datetime so the truncation is + deterministic regardless of the server timezone. + + Accepts: + - ``None`` (passed through; the DB column is NOT NULL so this only + matters for code paths that explicitly handle missing dates) + - ``str`` in ``YYYY-MM-DD`` (or ``YYYY-MM-DDTHH:...``) format + - ``datetime.date`` (not a ``datetime`` instance) + - ``datetime.datetime`` (made tz-aware as UTC if naive) + """ + if value is None: + return None + if isinstance(value, datetime): + return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + if isinstance(value, date): + return datetime(value.year, value.month, value.day, tzinfo=timezone.utc) + if isinstance(value, str): + date_part = value.split("T", 1)[0] + parsed = datetime.strptime(date_part, "%Y-%m-%d") + return parsed.replace(tzinfo=timezone.utc) + raise TypeError( + f"Cannot convert value of type {type(value).__name__!r} to a DB date" + ) + + +def to_date_str(value: Optional[DateLike]) -> Optional[str]: + """Normalize a date-like value to ``YYYY-MM-DD``. + + Used when grouping records returned from Prisma (where ``record.date`` is + a ``datetime``) and when rendering responses or chart keys. Returns + ``None`` if ``value`` is ``None`` so callers can pass through optional + fields. + """ + if value is None: + return None + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d") + if isinstance(value, date): + return value.isoformat() + if isinstance(value, str): + return value.split("T", 1)[0] + raise TypeError( + f"Cannot convert value of type {type(value).__name__!r} to a date string" + ) diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index c06e1850d9f..99bd31cfdd6 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -50,6 +50,7 @@ from litellm.proxy._types import ( SpendUpdateQueueItem, ToolDiscoveryQueueItem, ) +from litellm.proxy.db.daily_aggregate_date_utils import to_db_date from litellm.proxy.db.db_transaction_queue.daily_spend_update_queue import ( DailySpendUpdateQueue, ) @@ -1616,11 +1617,17 @@ class DBSpendUpdateWriter: for _, transaction in transactions_to_process.items(): entity_id = transaction.get(entity_id_field) + # The in-memory transaction stores `date` as a YYYY-MM-DD + # string (see BaseDailySpendTransaction). The DB column is + # `DateTime @db.Date`, so normalize to a UTC-midnight + # datetime at the Prisma boundary. + db_date = to_db_date(transaction["date"]) + # Construct the where clause dynamically where_clause = { unique_constraint_name: { entity_id_field: entity_id, - "date": transaction["date"], + "date": db_date, "api_key": transaction["api_key"], "model": transaction["model"], "custom_llm_provider": transaction.get( @@ -1641,7 +1648,7 @@ class DBSpendUpdateWriter: # Common data structure for both create and update common_data = { entity_id_field: entity_id, - "date": transaction["date"], + "date": db_date, "api_key": transaction["api_key"], "model": transaction.get("model"), "model_group": transaction.get("model_group"), diff --git a/litellm/proxy/guardrails/usage_endpoints.py b/litellm/proxy/guardrails/usage_endpoints.py index 529949c6dd8..3f83ada8cc3 100644 --- a/litellm/proxy/guardrails/usage_endpoints.py +++ b/litellm/proxy/guardrails/usage_endpoints.py @@ -12,6 +12,7 @@ from pydantic import BaseModel from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.db.daily_aggregate_date_utils import to_date_str, to_db_date router = APIRouter() @@ -124,7 +125,9 @@ def _prev_fail_rates(metrics_prev: Any, id_attr: str) -> Dict[str, float]: def _chart_from_metrics(metrics: Any) -> List[Dict[str, Any]]: chart_by_date: Dict[str, Dict[str, int]] = {} for m in metrics: - d = m.date + d = to_date_str(m.date) + if d is None: + continue if d not in chart_by_date: chart_by_date[d] = {"passed": 0, "blocked": 0} chart_by_date[d]["passed"] += int(m.passed_count or 0) @@ -352,13 +355,13 @@ async def guardrails_usage_detail( metrics = await prisma_client.db.litellm_dailyguardrailmetrics.find_many( where={ "guardrail_id": {"in": metric_ids}, - "date": {"gte": start, "lte": end}, + "date": {"gte": to_db_date(start), "lte": to_db_date(end)}, } ) metrics_prev = await prisma_client.db.litellm_dailyguardrailmetrics.find_many( where={ "guardrail_id": {"in": metric_ids}, - "date": {"lt": start}, + "date": {"lt": to_db_date(start)}, } ) @@ -374,7 +377,9 @@ async def guardrails_usage_detail( # Aggregate by date in case metrics exist under both UUID and logical name ts_by_date: Dict[str, Dict[str, Any]] = {} for m in metrics: - d = m.date + d = to_date_str(m.date) + if d is None: + continue if d not in ts_by_date: ts_by_date[d] = {"passed": 0, "blocked": 0} ts_by_date[d]["passed"] += int(m.passed_count or 0) @@ -647,15 +652,17 @@ async def policies_usage_overview( try: policies = await prisma_client.db.litellm_policytable.find_many() metrics = await prisma_client.db.litellm_dailypolicymetrics.find_many( - where={"date": {"gte": start, "lte": end}} + where={"date": {"gte": to_db_date(start), "lte": to_db_date(end)}} ) metrics_prev = await prisma_client.db.litellm_dailypolicymetrics.find_many( where={ "date": { - "gte": ( - datetime.strptime(start, "%Y-%m-%d") - timedelta(days=7) - ).strftime("%Y-%m-%d"), - "lt": start, + "gte": to_db_date( + ( + datetime.strptime(start, "%Y-%m-%d") - timedelta(days=7) + ).strftime("%Y-%m-%d") + ), + "lt": to_db_date(start), } } ) diff --git a/litellm/proxy/guardrails/usage_tracking.py b/litellm/proxy/guardrails/usage_tracking.py index 8907c9201ad..ae039f7fba5 100644 --- a/litellm/proxy/guardrails/usage_tracking.py +++ b/litellm/proxy/guardrails/usage_tracking.py @@ -9,6 +9,7 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional from litellm._logging import verbose_proxy_logger +from litellm.proxy.db.daily_aggregate_date_utils import to_db_date from litellm.proxy.utils import PrismaClient @@ -146,17 +147,18 @@ async def process_spend_logs_guardrail_usage( n = int(agg["requests_evaluated"]) if n == 0: continue + db_date = to_db_date(date_key) await prisma_client.db.litellm_dailyguardrailmetrics.upsert( where={ "guardrail_id_date": { "guardrail_id": guardrail_id, - "date": date_key, + "date": db_date, } }, data={ "create": { "guardrail_id": guardrail_id, - "date": date_key, + "date": db_date, "requests_evaluated": n, "passed_count": int(agg["passed_count"]), "blocked_count": int(agg["blocked_count"]), diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index ac66adc26f3..ab27f3f7507 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from types import SimpleNamespace from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union @@ -6,6 +6,7 @@ from fastapi import HTTPException, status from litellm._logging import verbose_proxy_logger from litellm.proxy._types import CommonProxyErrors +from litellm.proxy.db.daily_aggregate_date_utils import to_date_str, to_db_date from litellm.proxy.utils import PrismaClient from litellm.types.proxy.management_endpoints.common_daily_activity import ( BreakdownMetrics, @@ -438,8 +439,8 @@ def _build_where_conditions( where_conditions: Dict[str, Any] = { "date": { - "gte": adjusted_start, - "lte": adjusted_end, + "gte": to_db_date(adjusted_start), + "lte": to_db_date(adjusted_end), } } @@ -501,12 +502,13 @@ def _build_aggregated_sql_query( sql_params: List[Any] = [] p = 1 # parameter index (1-based for PostgreSQL $N placeholders) - # Date range (always present) - sql_conditions.append(f"date >= ${p}") + # Date range (always present). The column is DATE, so cast the string + # parameter at the SQL boundary to keep the types unambiguous. + sql_conditions.append(f"date >= ${p}::date") sql_params.append(adjusted_start) p += 1 - sql_conditions.append(f"date <= ${p}") + sql_conditions.append(f"date <= ${p}::date") sql_params.append(adjusted_end) p += 1 @@ -594,7 +596,12 @@ async def _aggregate_spend_records( grouped_data: Dict[str, Dict[str, Any]] = {} for record in records: - date_str = record.date + # `record.date` may be a `datetime`/`date` (Prisma) or a string + # (raw query result). Normalize to a YYYY-MM-DD key so grouping + # collapses the two representations into the same bucket. + date_str = to_date_str(record.date) + if date_str is None: + continue if date_str not in grouped_data: grouped_data[date_str] = { "metrics": SpendMetrics(), @@ -620,7 +627,7 @@ async def _aggregate_spend_records( for date_str, data in grouped_data.items(): results.append( DailySpendData( - date=datetime.strptime(date_str, "%Y-%m-%d").date(), + date=date.fromisoformat(date_str), metrics=data["metrics"], breakdown=data["breakdown"], ) diff --git a/litellm/proxy/management_endpoints/user_agent_analytics_endpoints.py b/litellm/proxy/management_endpoints/user_agent_analytics_endpoints.py index 872b6fa2250..999d8ac2a19 100644 --- a/litellm/proxy/management_endpoints/user_agent_analytics_endpoints.py +++ b/litellm/proxy/management_endpoints/user_agent_analytics_endpoints.py @@ -19,6 +19,7 @@ from pydantic import BaseModel from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.db.daily_aggregate_date_utils import to_date_str, to_db_date # Constants for analytics periods MAX_DAYS = 7 # Number of days to show in DAU analytics @@ -202,9 +203,11 @@ async def get_daily_active_users( start_dt = end_dt - timedelta(days=MAX_DAYS) start_date = start_dt.strftime("%Y-%m-%d") - # Build SQL query with optional tag filter(s) + # Build SQL query with optional tag filter(s). The DB column is now + # `DATE`, so cast string params to date at the placeholder. where_clause = ( - "WHERE dts.date >= $1 AND dts.date <= $2 AND vt.user_id IS NOT NULL" + "WHERE dts.date >= $1::date AND dts.date <= $2::date " + "AND vt.user_id IS NOT NULL" ) params = [start_date, end_date] @@ -236,7 +239,11 @@ async def get_daily_active_users( results = [ TagActiveUsersResponse( - tag=row["tag"], active_users=row["active_users"], date=row["date"] + tag=row["tag"], + active_users=row["active_users"], + # `dts.date` is now a DATE column; serialize back to YYYY-MM-DD + # for the response so the wire format is unchanged. + date=to_date_str(row["date"]) or "", ) for row in db_response ] @@ -310,7 +317,8 @@ async def get_weekly_active_users( # Build SQL query with optional tag filter(s) where_clause = ( - "WHERE dts.date >= $1 AND dts.date <= $2 AND vt.user_id IS NOT NULL" + "WHERE dts.date >= $1::date AND dts.date <= $2::date " + "AND vt.user_id IS NOT NULL" ) params = [start_date, end_date] @@ -439,7 +447,8 @@ async def get_monthly_active_users( # Build SQL query with optional tag filter(s) where_clause = ( - "WHERE dts.date >= $1 AND dts.date <= $2 AND vt.user_id IS NOT NULL" + "WHERE dts.date >= $1::date AND dts.date <= $2::date " + "AND vt.user_id IS NOT NULL" ) params = [start_date, end_date] @@ -551,7 +560,7 @@ async def get_tag_summary( datetime.strptime(end_date, "%Y-%m-%d") # Build SQL query with optional tag filter(s) - where_clause = "WHERE dts.date >= $1 AND dts.date <= $2" + where_clause = "WHERE dts.date >= $1::date AND dts.date <= $2::date" params = [start_date, end_date] # Handle multiple tag filters (takes precedence over single tag filter) @@ -667,7 +676,9 @@ async def get_per_user_analytics( start_date = start_dt.strftime("%Y-%m-%d") # Build where clause with date range - where_clause: Dict[str, Any] = {"date": {"gte": start_date, "lte": end_date}} + where_clause: Dict[str, Any] = { + "date": {"gte": to_db_date(start_date), "lte": to_db_date(end_date)} + } # Add tag filtering if provided if tag_filters and len(tag_filters) > 0: diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index a9d3911c07b..86f045a38ac 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -679,7 +679,7 @@ model LiteLLM_AuditLog { model LiteLLM_DailyUserSpend { id String @id @default(uuid()) user_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -710,7 +710,7 @@ model LiteLLM_DailyUserSpend { model LiteLLM_DailyOrganizationSpend { id String @id @default(uuid()) organization_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -741,7 +741,7 @@ model LiteLLM_DailyOrganizationSpend { model LiteLLM_DailyEndUserSpend { id String @id @default(uuid()) end_user_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -771,7 +771,7 @@ model LiteLLM_DailyEndUserSpend { model LiteLLM_DailyAgentSpend { id String @id @default(uuid()) agent_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -801,7 +801,7 @@ model LiteLLM_DailyAgentSpend { model LiteLLM_DailyTeamSpend { id String @id @default(uuid()) team_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -833,7 +833,7 @@ model LiteLLM_DailyTagSpend { id String @id @default(uuid()) request_id String? tag String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -962,7 +962,7 @@ model LiteLLM_GuardrailsTable { // Daily guardrail metrics for usage dashboard (one row per guardrail per day) model LiteLLM_DailyGuardrailMetrics { guardrail_id String // logical id; may not FK if guardrail from config - date String // YYYY-MM-DD + date DateTime @db.Date requests_evaluated BigInt @default(0) passed_count BigInt @default(0) blocked_count BigInt @default(0) @@ -980,7 +980,7 @@ model LiteLLM_DailyGuardrailMetrics { // Daily policy metrics for usage dashboard (one row per policy per day) model LiteLLM_DailyPolicyMetrics { policy_id String - date String // YYYY-MM-DD + date DateTime @db.Date requests_evaluated BigInt @default(0) passed_count BigInt @default(0) blocked_count BigInt @default(0) diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 948401fb8bf..dacee001c99 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -1,6 +1,6 @@ -from datetime import date +from datetime import date, datetime from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel, Field from typing_extensions import TypedDict @@ -108,7 +108,9 @@ class SpendAnalyticsPaginatedResponse(BaseModel): class LiteLLM_DailyUserSpend(BaseModel): id: str user_id: str - date: str + # Mirrors the DB column type. Pydantic v2 will accept ISO date strings + # (e.g. "2025-01-01") via either `date` or `datetime` and parse them. + date: Union[date, datetime] api_key: str mcp_server_id: Optional[str] = None model: Optional[str] = None diff --git a/schema.prisma b/schema.prisma index a9d3911c07b..86f045a38ac 100644 --- a/schema.prisma +++ b/schema.prisma @@ -679,7 +679,7 @@ model LiteLLM_AuditLog { model LiteLLM_DailyUserSpend { id String @id @default(uuid()) user_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -710,7 +710,7 @@ model LiteLLM_DailyUserSpend { model LiteLLM_DailyOrganizationSpend { id String @id @default(uuid()) organization_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -741,7 +741,7 @@ model LiteLLM_DailyOrganizationSpend { model LiteLLM_DailyEndUserSpend { id String @id @default(uuid()) end_user_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -771,7 +771,7 @@ model LiteLLM_DailyEndUserSpend { model LiteLLM_DailyAgentSpend { id String @id @default(uuid()) agent_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -801,7 +801,7 @@ model LiteLLM_DailyAgentSpend { model LiteLLM_DailyTeamSpend { id String @id @default(uuid()) team_id String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -833,7 +833,7 @@ model LiteLLM_DailyTagSpend { id String @id @default(uuid()) request_id String? tag String? - date String + date DateTime @db.Date api_key String model String? model_group String? @@ -962,7 +962,7 @@ model LiteLLM_GuardrailsTable { // Daily guardrail metrics for usage dashboard (one row per guardrail per day) model LiteLLM_DailyGuardrailMetrics { guardrail_id String // logical id; may not FK if guardrail from config - date String // YYYY-MM-DD + date DateTime @db.Date requests_evaluated BigInt @default(0) passed_count BigInt @default(0) blocked_count BigInt @default(0) @@ -980,7 +980,7 @@ model LiteLLM_DailyGuardrailMetrics { // Daily policy metrics for usage dashboard (one row per policy per day) model LiteLLM_DailyPolicyMetrics { policy_id String - date String // YYYY-MM-DD + date DateTime @db.Date requests_evaluated BigInt @default(0) passed_count BigInt @default(0) blocked_count BigInt @default(0) diff --git a/tests/test_litellm/proxy/db/test_daily_aggregate_date_utils.py b/tests/test_litellm/proxy/db/test_daily_aggregate_date_utils.py new file mode 100644 index 00000000000..cc5775d32d0 --- /dev/null +++ b/tests/test_litellm/proxy/db/test_daily_aggregate_date_utils.py @@ -0,0 +1,67 @@ +""" +Tests for daily-aggregate `date` field conversion helpers. + +These guard the boundary between the in-memory `YYYY-MM-DD` string representation +of a daily-aggregate transaction `date` and the `DateTime @db.Date` column +that Prisma now persists. +""" + +from datetime import date, datetime, timezone + +import pytest + +from litellm.proxy.db.daily_aggregate_date_utils import to_date_str, to_db_date + + +class TestToDbDate: + def test_should_convert_yyyy_mm_dd_string_to_utc_midnight_datetime(self): + result = to_db_date("2026-04-03") + assert result == datetime(2026, 4, 3, tzinfo=timezone.utc) + assert result.tzinfo is not None + + def test_should_strip_time_component_from_iso_string(self): + # In-memory transactions always carry a date-only string, but be lenient + # so we can also ingest stray "YYYY-MM-DDTHH:MM:SS" inputs without + # silently truncating to an unrelated date. + result = to_db_date("2026-04-03T18:30:00") + assert result == datetime(2026, 4, 3, tzinfo=timezone.utc) + + def test_should_convert_naive_datetime_to_utc_aware_datetime(self): + naive = datetime(2026, 4, 3, 0, 0, 0) + result = to_db_date(naive) + assert result == datetime(2026, 4, 3, tzinfo=timezone.utc) + + def test_should_preserve_aware_datetime_unchanged(self): + aware = datetime(2026, 4, 3, tzinfo=timezone.utc) + assert to_db_date(aware) is aware + + def test_should_convert_date_object_to_utc_midnight_datetime(self): + result = to_db_date(date(2026, 4, 3)) + assert result == datetime(2026, 4, 3, tzinfo=timezone.utc) + + def test_should_pass_through_none(self): + assert to_db_date(None) is None + + def test_should_raise_on_non_date_like_value(self): + with pytest.raises(TypeError): + to_db_date(12345) # type: ignore[arg-type] + + +class TestToDateStr: + def test_should_format_datetime_as_yyyy_mm_dd(self): + assert ( + to_date_str(datetime(2026, 4, 3, 18, 30, tzinfo=timezone.utc)) + == "2026-04-03" + ) + + def test_should_format_date_as_yyyy_mm_dd(self): + assert to_date_str(date(2026, 4, 3)) == "2026-04-03" + + def test_should_pass_through_yyyy_mm_dd_string(self): + assert to_date_str("2026-04-03") == "2026-04-03" + + def test_should_strip_time_component_from_iso_string(self): + assert to_date_str("2026-04-03T00:00:00+00:00") == "2026-04-03" + + def test_should_return_none_for_none(self): + assert to_date_str(None) is None diff --git a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py index 4d584349342..3ca0f42200b 100644 --- a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py +++ b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py @@ -124,7 +124,9 @@ async def test_update_daily_spend_with_null_entity_id(): "user_id_date_api_key_model_custom_llm_provider_mcp_namespaced_tool_name_endpoint" ] assert where_clause["user_id"] is None - assert where_clause["date"] == "2024-01-01" + # `date` is converted from the in-memory YYYY-MM-DD string to a UTC-midnight + # datetime at the Prisma boundary (DB column is `DateTime @db.Date`). + assert where_clause["date"] == datetime(2024, 1, 1, tzinfo=timezone.utc) assert where_clause["api_key"] == "test-api-key" assert where_clause["model"] == "gpt-4" assert where_clause["custom_llm_provider"] == "openai" @@ -134,7 +136,7 @@ async def test_update_daily_spend_with_null_entity_id(): # Verify the create data contains null entity_id create_data = call_args["data"]["create"] assert create_data["user_id"] is None - assert create_data["date"] == "2024-01-01" + assert create_data["date"] == datetime(2024, 1, 1, tzinfo=timezone.utc) assert create_data["api_key"] == "test-api-key" assert create_data["model"] == "gpt-4" assert create_data["custom_llm_provider"] == "openai" @@ -180,12 +182,13 @@ async def test_update_daily_spend_sorting(): "successful_requests": 1, "failed_requests": 0, } + expected_db_date = datetime(2024, 1, 1, tzinfo=timezone.utc) upsert_calls.append( call( where={ "user_id_date_api_key_model_custom_llm_provider_mcp_namespaced_tool_name_endpoint": { "user_id": f"user{i+11}", # user11 ... user60, sorted order - "date": "2024-01-01", + "date": expected_db_date, "api_key": "test-api-key", "model": "gpt-4", "custom_llm_provider": "openai", @@ -196,7 +199,7 @@ async def test_update_daily_spend_sorting(): data={ "create": { "user_id": f"user{i+11}", - "date": "2024-01-01", + "date": expected_db_date, "api_key": "test-api-key", "model": "gpt-4", "model_group": None,