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 <mateo-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-05-02 18:47:53 +00:00
parent c94a8d6514
commit 4c3ce93f77
No known key found for this signature in database
14 changed files with 333 additions and 59 deletions

View file

@ -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 $$;

View file

@ -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)

View file

@ -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]

View file

@ -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"
)

View file

@ -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"),

View file

@ -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),
}
}
)

View file

@ -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"]),

View file

@ -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"],
)

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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,