mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
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:
parent
c94a8d6514
commit
4c3ce93f77
14 changed files with 333 additions and 59 deletions
|
|
@ -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 $$;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
66
litellm/proxy/db/daily_aggregate_date_utils.py
Normal file
66
litellm/proxy/db/daily_aggregate_date_utils.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue