feat: 统计数据优化 - 支持细粒度时间范围和多维度分析

- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计
- 实现 UTC datetime 范围查询策略,边界数据实时聚合
- 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类
- 新增前端页面:成本分析、性能分析、用户统计
- 新增 TimeRangePicker 组件和统计可视化组件
- 优化 Dashboard 和 Usage 页面支持时间范围筛选

Close #135
This commit is contained in:
fawney19
2026-02-04 02:04:54 +08:00
parent 6f363a7703
commit f3e2f84b38
59 changed files with 6392 additions and 500 deletions

View File

@@ -15,6 +15,7 @@ from .provider_query import router as provider_query_router
from .provider_strategy import router as provider_strategy_router
from .providers import router as providers_router
from .security import router as security_router
from .stats import router as stats_router
from .system import router as system_router
from .usage import router as usage_router
from .users import router as users_router
@@ -33,6 +34,7 @@ router.include_router(provider_strategy_router)
router.include_router(adaptive_router)
router.include_router(models_router)
router.include_router(security_router)
router.include_router(stats_router)
router.include_router(provider_query_router)
router.include_router(management_tokens_router)
router.include_router(modules_router)

View File

@@ -0,0 +1,22 @@
"""Stats admin routes export."""
from fastapi import APIRouter
from .comparison import router as comparison_router
from .cost import router as cost_router
from .errors import router as errors_router
from .leaderboard import router as leaderboard_router
from .performance import router as performance_router
from .quota import router as quota_router
from .time_series import router as time_series_router
router = APIRouter(prefix="/api/admin/stats", tags=["Admin - Stats"])
router.include_router(leaderboard_router)
router.include_router(time_series_router)
router.include_router(cost_router)
router.include_router(quota_router)
router.include_router(performance_router)
router.include_router(errors_router)
router.include_router(comparison_router)
__all__ = ["router"]

View File

@@ -0,0 +1,196 @@
"""Shared helpers for admin stats routes."""
from __future__ import annotations
import hashlib
import json
from datetime import date, datetime, time, timedelta, timezone
from typing import Any, Literal
from fastapi import HTTPException
from sqlalchemy import and_, or_
from src.api.base.pipeline import ApiRequestPipeline
from src.config.settings import config
from src.models.database import Usage
from src.services.system.time_range import TimeRangeParams
pipeline = ApiRequestPipeline()
def _apply_admin_default_range(
params: TimeRangeParams | None,
) -> TimeRangeParams | None:
"""Apply a default range to avoid unbounded scans."""
if params is not None:
return params
days = int(getattr(config, "admin_usage_default_days", 0) or 0)
if days <= 0:
return None
today = datetime.now(timezone.utc).date()
start_date = today - timedelta(days=days - 1)
return TimeRangeParams(
start_date=start_date,
end_date=today,
timezone="UTC",
tz_offset_minutes=0,
).validate_and_resolve()
def _build_time_range_params(
start_date: date | None,
end_date: date | None,
preset: str | None,
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> TimeRangeParams | None:
if not preset and start_date is None and end_date is None:
return None
try:
return TimeRangeParams(
start_date=start_date,
end_date=end_date,
preset=preset,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _hash_filters(filters: dict[str, Any]) -> str:
raw = json.dumps(filters, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
def _build_time_range_from_days(
days: int, timezone_name: str | None, tz_offset_minutes: int | None
) -> TimeRangeParams:
base = TimeRangeParams(
preset="today",
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
user_today = base.start_date
start_date = user_today - timedelta(days=days - 1)
return TimeRangeParams(
start_date=start_date,
end_date=user_today,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
def _linear_regression(values: list[float]) -> tuple[float, float]:
n = len(values)
if n <= 1:
return 0.0, values[0] if values else 0.0
xs = list(range(n))
sum_x = sum(xs)
sum_y = sum(values)
sum_x2 = sum(x * x for x in xs)
sum_xy = sum(x * y for x, y in zip(xs, values))
denom = n * sum_x2 - sum_x * sum_x
if denom == 0:
return 0.0, values[-1]
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n
return slope, intercept
def _build_cache_key(
leaderboard_type: str,
metric: str,
time_range: TimeRangeParams | None,
filters: dict[str, Any],
) -> str:
start_value = time_range.start_date.isoformat() if time_range else "all"
end_value = time_range.end_date.isoformat() if time_range else "all"
tz_value = time_range.timezone if time_range else "utc"
offset_value = time_range.tz_offset_minutes if time_range else 0
return (
f"leaderboard:{leaderboard_type}:{metric}:{start_value}:{end_value}:"
f"{tz_value}:{offset_value}:{_hash_filters(filters)}"
)
def _is_today_range(time_range: TimeRangeParams | None) -> bool:
if not time_range:
return False
try:
user_today = time_range._get_user_today()
except Exception:
return False
return time_range.end_date == user_today
def _split_daily_and_usage_segments(
time_range: TimeRangeParams | None,
use_daily: bool,
) -> tuple[tuple[datetime, datetime] | None, list[tuple[datetime, datetime]] | None]:
if not time_range:
return None, None
start_utc, end_utc = time_range.to_utc_datetime_range()
if not use_daily:
return None, [(start_utc, end_utc)]
complete_dates, head_boundary, tail_boundary = time_range.get_complete_utc_dates()
daily_range = None
if complete_dates:
daily_start = datetime.combine(complete_dates[0], time.min, tzinfo=timezone.utc)
daily_end = datetime.combine(
complete_dates[-1] + timedelta(days=1), time.min, tzinfo=timezone.utc
)
daily_range = (daily_start, daily_end)
usage_segments: list[tuple[datetime, datetime]] = []
if head_boundary:
usage_segments.append(head_boundary)
if tail_boundary:
usage_segments.append(tail_boundary)
if not daily_range and not usage_segments:
usage_segments = [(start_utc, end_utc)]
return daily_range, usage_segments
def _apply_usage_time_segments(
query: Any, segments: list[tuple[datetime, datetime]] | None
) -> Any | None:
if segments is None:
return query
if not segments:
return None
conditions = []
for start_utc, end_utc in segments:
if start_utc >= end_utc:
continue
conditions.append(and_(Usage.created_at >= start_utc, Usage.created_at < end_utc))
if not conditions:
return None
return query.filter(or_(*conditions))
def _union_queries(queries: list[Any]) -> Any | None:
base = None
for query in queries:
if query is None:
continue
if base is None:
base = query
else:
base = base.union_all(query)
return base
def _metric_order(
metric: Literal["requests", "tokens", "cost"], order: Literal["asc", "desc"], expr: Any
) -> Any:
return expr.asc() if order == "asc" else expr.desc()

View File

@@ -0,0 +1,128 @@
"""Admin comparison stats routes."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.database import get_db
from src.services.system.stats_aggregator import AggregatedStats, StatsFilter, query_stats_hybrid
from src.services.system.time_range import TimeRangeParams
from .common import pipeline
router = APIRouter()
class AdminComparisonAdapter(AdminApiAdapter):
def __init__(
self,
current_start: date,
current_end: date,
comparison_type: Literal["period", "year"],
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> None:
self.current_start = current_start
self.current_end = current_end
self.comparison_type = comparison_type
self.timezone_name = timezone_name
self.tz_offset_minutes = tz_offset_minutes
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if self.current_start > self.current_end:
raise HTTPException(status_code=400, detail="current_start must be <= current_end")
days = (self.current_end - self.current_start).days + 1
def _safe_year_shift(value: date) -> date:
try:
return value.replace(year=value.year - 1)
except ValueError:
return value.replace(year=value.year - 1, day=28)
if self.comparison_type == "period":
comparison_end = self.current_start - timedelta(days=1)
comparison_start = comparison_end - timedelta(days=days - 1)
else:
comparison_start = _safe_year_shift(self.current_start)
comparison_end = _safe_year_shift(self.current_end)
current_range = TimeRangeParams(
start_date=self.current_start,
end_date=self.current_end,
timezone=self.timezone_name,
tz_offset_minutes=self.tz_offset_minutes or 0,
).validate_and_resolve()
comparison_range = TimeRangeParams(
start_date=comparison_start,
end_date=comparison_end,
timezone=self.timezone_name,
tz_offset_minutes=self.tz_offset_minutes or 0,
).validate_and_resolve()
current_stats = query_stats_hybrid(context.db, current_range, filters=StatsFilter())
comparison_stats = query_stats_hybrid(context.db, comparison_range, filters=StatsFilter())
def _stats_payload(stats: AggregatedStats) -> dict[str, Any]:
total_tokens = (
stats.input_tokens
+ stats.output_tokens
+ stats.cache_creation_tokens
+ stats.cache_read_tokens
)
return {
"total_requests": stats.total_requests,
"total_tokens": total_tokens,
"total_cost": float(stats.total_cost),
"actual_total_cost": float(stats.actual_total_cost),
"avg_response_time_ms": float(stats.avg_response_time_ms),
"error_requests": stats.error_requests,
}
def _pct_change(current: float, previous: float) -> float | None:
if previous == 0:
return None if current != 0 else 0.0
return round((current - previous) / previous * 100, 2)
current_payload = _stats_payload(current_stats)
comparison_payload = _stats_payload(comparison_stats)
changes = {
key: _pct_change(float(current_payload[key]), float(comparison_payload[key]))
for key in current_payload.keys()
}
return {
"current": current_payload,
"comparison": comparison_payload,
"change_percent": changes,
"current_start": self.current_start.isoformat(),
"current_end": self.current_end.isoformat(),
"comparison_start": comparison_start.isoformat(),
"comparison_end": comparison_end.isoformat(),
}
@router.get("/comparison")
async def get_comparison(
request: Request,
db: Session = Depends(get_db),
current_start: date = Query(...),
current_end: date = Query(...),
comparison_type: Literal["period", "year"] = Query("period"),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
) -> Any:
adapter = AdminComparisonAdapter(
current_start=current_start,
current_end=current_end,
comparison_type=comparison_type,
timezone_name=timezone_name,
tz_offset_minutes=tz_offset_minutes,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

183
src/api/admin/stats/cost.py Normal file
View File

@@ -0,0 +1,183 @@
"""Admin cost stats routes."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.database import get_db
from src.models.database import Usage
from src.services.system.stats_aggregator import query_time_series
from src.services.system.time_range import TimeRangeParams
from .common import (
_apply_admin_default_range,
_build_time_range_from_days,
_build_time_range_params,
_linear_regression,
pipeline,
)
router = APIRouter()
class AdminCostForecastAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
days: int,
forecast_days: int,
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> None:
self.time_range = time_range
self.days = days
self.forecast_days = forecast_days
self.timezone_name = timezone_name
self.tz_offset_minutes = tz_offset_minutes
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
time_range = self.time_range or _build_time_range_from_days(
self.days, self.timezone_name, self.tz_offset_minutes
)
time_range.granularity = "day"
try:
series = query_time_series(context.db, time_range)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
history = [
{"date": item["date"], "total_cost": float(item.get("total_cost", 0.0))}
for item in series
]
values = [item["total_cost"] for item in history]
slope, intercept = _linear_regression(values)
forecast = []
if history:
last_date = date.fromisoformat(history[-1]["date"])
else:
last_date = time_range.end_date
for i in range(self.forecast_days):
idx = len(values) + i
predicted = max(0.0, slope * idx + intercept)
forecast.append(
{
"date": (last_date + timedelta(days=i + 1)).isoformat(),
"total_cost": round(predicted, 4),
}
)
return {
"history": history,
"forecast": forecast,
"slope": round(slope, 6),
"intercept": round(intercept, 6),
"start_date": time_range.start_date.isoformat(),
"end_date": time_range.end_date.isoformat(),
}
@router.get("/cost/forecast")
async def get_cost_forecast(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
days: int = Query(30, ge=7, le=365),
forecast_days: int = Query(7, ge=1, le=90),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminCostForecastAdapter(
time_range=time_range,
days=days,
forecast_days=forecast_days,
timezone_name=timezone_name,
tz_offset_minutes=tz_offset_minutes,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
class AdminCostSavingsAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
provider_name: str | None,
model: str | None,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.provider_name = provider_name
self.model = model
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return {
"cache_read_tokens": 0,
"cache_read_cost": 0.0,
"cache_creation_cost": 0.0,
"estimated_full_cost": 0.0,
"cache_savings": 0.0,
}
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = context.db.query(
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(
func.coalesce(Usage.output_price_per_1m, 0) * Usage.cache_read_input_tokens
).label("estimated_full_cost_raw"),
).filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
if self.provider_name:
query = query.filter(Usage.provider_name == self.provider_name)
if self.model:
query = query.filter(Usage.model == self.model)
row = query.first()
cache_read_tokens = int(getattr(row, "cache_read_tokens", 0) or 0)
cache_read_cost = float(getattr(row, "cache_read_cost", 0) or 0.0)
cache_creation_cost = float(getattr(row, "cache_creation_cost", 0) or 0.0)
estimated_full_cost = float(getattr(row, "estimated_full_cost_raw", 0) or 0.0) / 1_000_000
if estimated_full_cost <= 0 and cache_read_cost > 0:
estimated_full_cost = cache_read_cost * 10
cache_savings = estimated_full_cost - cache_read_cost
return {
"cache_read_tokens": cache_read_tokens,
"cache_read_cost": round(cache_read_cost, 6),
"cache_creation_cost": round(cache_creation_cost, 6),
"estimated_full_cost": round(estimated_full_cost, 6),
"cache_savings": round(cache_savings, 6),
}
@router.get("/cost/savings")
async def get_cost_savings(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminCostSavingsAdapter(
time_range=time_range, provider_name=provider_name, model=model
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,111 @@
"""Admin error stats routes."""
from __future__ import annotations
from datetime import date, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.database import get_db
from src.models.database import StatsDailyError, Usage
from src.services.system.time_range import TimeRangeParams
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
router = APIRouter()
class AdminErrorDistributionAdapter(AdminApiAdapter):
def __init__(self, time_range: TimeRangeParams | None) -> None:
self.time_range = _apply_admin_default_range(time_range)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return {"distribution": [], "trend": []}
time_range = self.time_range
is_utc = (time_range.timezone in {None, "UTC"}) and time_range.tz_offset_minutes == 0
distribution: dict[str, int] = {}
trend: dict[str, dict[str, int]] = {}
if is_utc:
start_utc, end_utc = time_range.to_utc_datetime_range()
rows = (
context.db.query(StatsDailyError)
.filter(StatsDailyError.date >= start_utc, StatsDailyError.date < end_utc)
.all()
)
for row in rows:
date_str = (
row.date.astimezone(timezone.utc).date().isoformat()
if row.date.tzinfo
else row.date.date().isoformat()
)
distribution[row.error_category] = distribution.get(row.error_category, 0) + int(
row.count or 0
)
trend.setdefault(date_str, {})
trend[date_str][row.error_category] = trend[date_str].get(
row.error_category, 0
) + int(row.count or 0)
else:
for local_date, day_start_utc, day_end_utc in time_range.get_local_day_hours():
rows = (
context.db.query(
Usage.error_category,
func.count(Usage.id).label("count"),
)
.filter(
Usage.created_at >= day_start_utc,
Usage.created_at < day_end_utc,
Usage.error_category.isnot(None),
)
.group_by(Usage.error_category)
.all()
)
date_str = local_date.isoformat()
for row in rows:
distribution[row.error_category] = distribution.get(
row.error_category, 0
) + int(row.count or 0)
trend.setdefault(date_str, {})
trend[date_str][row.error_category] = trend[date_str].get(
row.error_category, 0
) + int(row.count or 0)
trend_items = []
for day in sorted(trend.keys()):
counts = trend[day]
total = sum(counts.values())
trend_items.append({"date": day, "total": total, "categories": counts})
distribution_items = [
{"category": category, "count": count}
for category, count in sorted(
distribution.items(), key=lambda item: item[1], reverse=True
)
]
return {"distribution": distribution_items, "trend": trend_items}
@router.get("/errors/distribution")
async def get_error_distribution(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminErrorDistributionAdapter(time_range=time_range)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,759 @@
"""Admin leaderboard stats routes."""
from __future__ import annotations
import json
from datetime import date
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.clients.redis_client import get_redis_client_sync
from src.config.constants import CacheTTL
from src.core.enums import UserRole
from src.database import get_db
from src.models.database import (
ApiKey,
StatsDailyApiKey,
StatsDailyModel,
StatsUserDaily,
Usage,
User,
)
from src.services.system.time_range import TimeRangeParams
from .common import (
_apply_admin_default_range,
_apply_usage_time_segments,
_build_cache_key,
_build_time_range_params,
_is_today_range,
_metric_order,
_split_daily_and_usage_segments,
_union_queries,
pipeline,
)
router = APIRouter()
class AdminUserLeaderboardAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
metric: Literal["requests", "tokens", "cost"],
order: Literal["asc", "desc"],
limit: int,
offset: int,
provider_name: str | None,
model: str | None,
include_inactive: bool,
exclude_admin: bool,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.metric = metric
self.order = order
self.limit = limit
self.offset = offset
self.provider_name = provider_name
self.model = model
self.include_inactive = include_inactive
self.exclude_admin = exclude_admin
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
cacheable = not _is_today_range(self.time_range)
redis_client = get_redis_client_sync()
cache_key = None
if cacheable and redis_client:
cache_key = _build_cache_key(
"users",
self.metric,
self.time_range,
{
"order": self.order,
"limit": self.limit,
"offset": self.offset,
"provider_name": self.provider_name,
"model": self.model,
"include_inactive": self.include_inactive,
"exclude_admin": self.exclude_admin,
},
)
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached)
except Exception:
pass
use_daily = self.time_range is not None and not self.provider_name and not self.model
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
daily_query = None
if daily_range:
daily_query = (
db.query(
StatsUserDaily.user_id.label("entity_id"),
func.sum(StatsUserDaily.total_requests).label("requests"),
func.sum(
StatsUserDaily.input_tokens
+ StatsUserDaily.output_tokens
+ StatsUserDaily.cache_creation_tokens
+ StatsUserDaily.cache_read_tokens
).label("tokens"),
func.sum(StatsUserDaily.total_cost).label("cost"),
)
.filter(StatsUserDaily.date >= daily_range[0], StatsUserDaily.date < daily_range[1])
.group_by(StatsUserDaily.user_id)
)
usage_query = db.query(
Usage.user_id.label("entity_id"),
func.count(Usage.id).label("requests"),
func.sum(
Usage.input_tokens
+ Usage.output_tokens
+ Usage.cache_creation_input_tokens
+ Usage.cache_read_input_tokens
).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
).filter(
Usage.user_id.isnot(None),
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
)
if self.provider_name:
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
if self.model:
usage_query = usage_query.filter(Usage.model == self.model)
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
if usage_query is not None:
usage_query = usage_query.group_by(Usage.user_id)
union_query = _union_queries([daily_query, usage_query])
if union_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
union_subq = union_query.subquery()
agg_subq = (
db.query(
union_subq.c.entity_id.label("entity_id"),
func.sum(union_subq.c.requests).label("requests"),
func.sum(union_subq.c.tokens).label("tokens"),
func.sum(union_subq.c.cost).label("cost"),
)
.group_by(union_subq.c.entity_id)
.subquery()
)
base_query = (
db.query(
User.id.label("id"),
User.username,
User.email,
agg_subq.c.requests,
agg_subq.c.tokens,
agg_subq.c.cost,
)
.join(agg_subq, agg_subq.c.entity_id == User.id)
.filter(User.is_deleted.is_(False))
)
if not self.include_inactive:
base_query = base_query.filter(User.is_active.is_(True))
if self.exclude_admin:
base_query = base_query.filter(User.role != UserRole.ADMIN)
metric_expr = {
"requests": agg_subq.c.requests,
"tokens": agg_subq.c.tokens,
"cost": agg_subq.c.cost,
}[self.metric]
order_expr = _metric_order(self.metric, self.order, metric_expr)
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
rows = (
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
.order_by(order_expr)
.offset(self.offset)
.limit(self.limit)
.all()
)
items = []
for row in rows:
name = row.username or row.email or str(row.id)
value = row.metric_value or 0
if self.metric in {"requests", "tokens"}:
value = int(value)
else:
value = float(value)
items.append(
{
"rank": int(row.rank),
"id": row.id,
"name": name,
"value": value,
"requests": int(row.requests or 0),
"tokens": int(row.tokens or 0),
"cost": float(row.cost or 0.0),
}
)
context.add_audit_metadata(
action="leaderboard_users",
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
preset=self.time_range.preset if self.time_range else None,
timezone=self.time_range.timezone if self.time_range else None,
metric=self.metric,
order=self.order,
limit=self.limit,
offset=self.offset,
provider_name=self.provider_name,
model=self.model,
include_inactive=self.include_inactive,
exclude_admin=self.exclude_admin,
result_count=len(items),
total=total,
)
result = {
"items": items,
"total": total,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
if cacheable and redis_client and cache_key:
try:
await redis_client.setex(
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
)
except Exception:
pass
return result
class AdminApiKeyLeaderboardAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
metric: Literal["requests", "tokens", "cost"],
order: Literal["asc", "desc"],
limit: int,
offset: int,
provider_name: str | None,
model: str | None,
include_inactive: bool,
exclude_admin: bool,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.metric = metric
self.order = order
self.limit = limit
self.offset = offset
self.provider_name = provider_name
self.model = model
self.include_inactive = include_inactive
self.exclude_admin = exclude_admin
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
cacheable = not _is_today_range(self.time_range)
redis_client = get_redis_client_sync()
cache_key = None
if cacheable and redis_client:
cache_key = _build_cache_key(
"api_keys",
self.metric,
self.time_range,
{
"order": self.order,
"limit": self.limit,
"offset": self.offset,
"provider_name": self.provider_name,
"model": self.model,
"include_inactive": self.include_inactive,
"exclude_admin": self.exclude_admin,
},
)
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached)
except Exception:
pass
use_daily = self.time_range is not None and not self.provider_name and not self.model
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
daily_query = None
if daily_range:
daily_query = (
db.query(
StatsDailyApiKey.api_key_id.label("entity_id"),
func.sum(StatsDailyApiKey.total_requests).label("requests"),
func.sum(
StatsDailyApiKey.input_tokens
+ StatsDailyApiKey.output_tokens
+ StatsDailyApiKey.cache_creation_tokens
+ StatsDailyApiKey.cache_read_tokens
).label("tokens"),
func.sum(StatsDailyApiKey.total_cost).label("cost"),
)
.filter(
StatsDailyApiKey.date >= daily_range[0], StatsDailyApiKey.date < daily_range[1]
)
.group_by(StatsDailyApiKey.api_key_id)
)
usage_query = db.query(
Usage.api_key_id.label("entity_id"),
func.count(Usage.id).label("requests"),
func.sum(
Usage.input_tokens
+ Usage.output_tokens
+ Usage.cache_creation_input_tokens
+ Usage.cache_read_input_tokens
).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
).filter(
Usage.api_key_id.isnot(None),
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
)
if self.provider_name:
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
if self.model:
usage_query = usage_query.filter(Usage.model == self.model)
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
if usage_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
usage_query = usage_query.group_by(Usage.api_key_id)
union_query = _union_queries([daily_query, usage_query])
if union_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
union_subq = union_query.subquery()
agg_subq = (
db.query(
union_subq.c.entity_id.label("entity_id"),
func.sum(union_subq.c.requests).label("requests"),
func.sum(union_subq.c.tokens).label("tokens"),
func.sum(union_subq.c.cost).label("cost"),
)
.group_by(union_subq.c.entity_id)
.subquery()
)
base_query = (
db.query(
ApiKey,
User,
agg_subq.c.requests,
agg_subq.c.tokens,
agg_subq.c.cost,
)
.join(agg_subq, agg_subq.c.entity_id == ApiKey.id)
.join(User, User.id == ApiKey.user_id)
.filter(User.is_deleted.is_(False))
)
if not self.include_inactive:
base_query = base_query.filter(ApiKey.is_active.is_(True))
if self.exclude_admin:
base_query = base_query.filter(User.role != UserRole.ADMIN)
metric_expr = {
"requests": agg_subq.c.requests,
"tokens": agg_subq.c.tokens,
"cost": agg_subq.c.cost,
}[self.metric]
order_expr = _metric_order(self.metric, self.order, metric_expr)
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
rows = (
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
.order_by(order_expr)
.offset(self.offset)
.limit(self.limit)
.all()
)
items = []
for row in rows:
api_key = row.ApiKey
name = api_key.name or api_key.get_display_key()
value = row.metric_value or 0
if self.metric in {"requests", "tokens"}:
value = int(value)
else:
value = float(value)
items.append(
{
"rank": int(row.rank),
"id": api_key.id,
"name": name,
"value": value,
"requests": int(row.requests or 0),
"tokens": int(row.tokens or 0),
"cost": float(row.cost or 0.0),
}
)
context.add_audit_metadata(
action="leaderboard_api_keys",
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
preset=self.time_range.preset if self.time_range else None,
timezone=self.time_range.timezone if self.time_range else None,
metric=self.metric,
order=self.order,
limit=self.limit,
offset=self.offset,
provider_name=self.provider_name,
model=self.model,
include_inactive=self.include_inactive,
exclude_admin=self.exclude_admin,
result_count=len(items),
total=total,
)
result = {
"items": items,
"total": total,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
if cacheable and redis_client and cache_key:
try:
await redis_client.setex(
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
)
except Exception:
pass
return result
class AdminModelLeaderboardAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
metric: Literal["requests", "tokens", "cost"],
order: Literal["asc", "desc"],
limit: int,
offset: int,
provider_name: str | None,
model: str | None,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.metric = metric
self.order = order
self.limit = limit
self.offset = offset
self.provider_name = provider_name
self.model = model
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
cacheable = not _is_today_range(self.time_range)
redis_client = get_redis_client_sync()
cache_key = None
if cacheable and redis_client:
cache_key = _build_cache_key(
"models",
self.metric,
self.time_range,
{
"order": self.order,
"limit": self.limit,
"offset": self.offset,
"provider_name": self.provider_name,
"model": self.model,
},
)
cached = await redis_client.get(cache_key)
if cached:
try:
return json.loads(cached)
except Exception:
pass
use_daily = self.time_range is not None and not self.provider_name
daily_range, usage_segments = _split_daily_and_usage_segments(self.time_range, use_daily)
daily_query = None
if daily_range:
daily_query = (
db.query(
StatsDailyModel.model.label("entity_id"),
func.sum(StatsDailyModel.total_requests).label("requests"),
func.sum(
StatsDailyModel.input_tokens
+ StatsDailyModel.output_tokens
+ StatsDailyModel.cache_creation_tokens
+ StatsDailyModel.cache_read_tokens
).label("tokens"),
func.sum(StatsDailyModel.total_cost).label("cost"),
)
.filter(
StatsDailyModel.date >= daily_range[0], StatsDailyModel.date < daily_range[1]
)
.group_by(StatsDailyModel.model)
)
if self.model:
daily_query = daily_query.filter(StatsDailyModel.model == self.model)
usage_query = db.query(
Usage.model.label("entity_id"),
func.count(Usage.id).label("requests"),
func.sum(
Usage.input_tokens
+ Usage.output_tokens
+ Usage.cache_creation_input_tokens
+ Usage.cache_read_input_tokens
).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
).filter(
Usage.status.notin_(["pending", "streaming"]),
Usage.provider_name.notin_(["unknown", "pending"]),
)
if self.provider_name:
usage_query = usage_query.filter(Usage.provider_name == self.provider_name)
if self.model:
usage_query = usage_query.filter(Usage.model == self.model)
usage_query = _apply_usage_time_segments(usage_query, usage_segments)
if usage_query is not None:
usage_query = usage_query.group_by(Usage.model)
union_query = _union_queries([daily_query, usage_query])
if union_query is None:
return {
"items": [],
"total": 0,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
union_subq = union_query.subquery()
agg_subq = (
db.query(
union_subq.c.entity_id.label("entity_id"),
func.sum(union_subq.c.requests).label("requests"),
func.sum(union_subq.c.tokens).label("tokens"),
func.sum(union_subq.c.cost).label("cost"),
)
.group_by(union_subq.c.entity_id)
.subquery()
)
base_query = db.query(
agg_subq.c.entity_id.label("id"),
agg_subq.c.entity_id.label("name"),
agg_subq.c.requests,
agg_subq.c.tokens,
agg_subq.c.cost,
)
metric_expr = {
"requests": agg_subq.c.requests,
"tokens": agg_subq.c.tokens,
"cost": agg_subq.c.cost,
}[self.metric]
order_expr = _metric_order(self.metric, self.order, metric_expr)
rank_expr = func.dense_rank().over(order_by=order_expr).label("rank")
total = db.query(func.count()).select_from(base_query.subquery()).scalar() or 0
rows = (
base_query.add_columns(rank_expr, metric_expr.label("metric_value"))
.order_by(order_expr)
.offset(self.offset)
.limit(self.limit)
.all()
)
items = []
for row in rows:
value = row.metric_value or 0
if self.metric in {"requests", "tokens"}:
value = int(value)
else:
value = float(value)
items.append(
{
"rank": int(row.rank),
"id": row.id,
"name": row.name,
"value": value,
"requests": int(row.requests or 0),
"tokens": int(row.tokens or 0),
"cost": float(row.cost or 0.0),
}
)
context.add_audit_metadata(
action="leaderboard_models",
start_date=self.time_range.start_date.isoformat() if self.time_range else None,
end_date=self.time_range.end_date.isoformat() if self.time_range else None,
preset=self.time_range.preset if self.time_range else None,
timezone=self.time_range.timezone if self.time_range else None,
metric=self.metric,
order=self.order,
limit=self.limit,
offset=self.offset,
provider_name=self.provider_name,
model=self.model,
result_count=len(items),
total=total,
)
result = {
"items": items,
"total": total,
"metric": self.metric,
"start_date": self.time_range.start_date.isoformat() if self.time_range else None,
"end_date": self.time_range.end_date.isoformat() if self.time_range else None,
}
if cacheable and redis_client and cache_key:
try:
await redis_client.setex(
cache_key, CacheTTL.ADMIN_LEADERBOARD, json.dumps(result, ensure_ascii=False)
)
except Exception:
pass
return result
@router.get("/leaderboard/users")
async def get_user_leaderboard(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
order: Literal["desc", "asc"] = Query("desc"),
limit: int = Query(10, ge=1, le=100),
offset: int = Query(0, ge=0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
include_inactive: bool = Query(False),
exclude_admin: bool = Query(False),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminUserLeaderboardAdapter(
time_range=time_range,
metric=metric,
order=order,
limit=limit,
offset=offset,
provider_name=provider_name,
model=model,
include_inactive=include_inactive,
exclude_admin=exclude_admin,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/leaderboard/api-keys")
async def get_api_key_leaderboard(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
order: Literal["desc", "asc"] = Query("desc"),
limit: int = Query(10, ge=1, le=100),
offset: int = Query(0, ge=0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
include_inactive: bool = Query(False),
exclude_admin: bool = Query(False),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminApiKeyLeaderboardAdapter(
time_range=time_range,
metric=metric,
order=order,
limit=limit,
offset=offset,
provider_name=provider_name,
model=model,
include_inactive=include_inactive,
exclude_admin=exclude_admin,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/leaderboard/models")
async def get_model_leaderboard(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
metric: Literal["requests", "tokens", "cost"] = Query("requests"),
order: Literal["desc", "asc"] = Query("desc"),
limit: int = Query(10, ge=1, le=100),
offset: int = Query(0, ge=0),
provider_name: str | None = Query(None),
model: str | None = Query(None),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminModelLeaderboardAdapter(
time_range=time_range,
metric=metric,
order=order,
limit=limit,
offset=offset,
provider_name=provider_name,
model=model,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,85 @@
"""Admin performance stats routes."""
from __future__ import annotations
from datetime import date, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.database import get_db
from src.models.database import StatsDaily
from src.services.system.stats_aggregator import StatsAggregatorService
from src.services.system.time_range import TimeRangeParams
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
router = APIRouter()
class AdminPercentilesAdapter(AdminApiAdapter):
def __init__(self, time_range: TimeRangeParams | None) -> None:
self.time_range = _apply_admin_default_range(time_range)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return []
time_range = self.time_range
is_utc = (time_range.timezone in {None, "UTC"}) and time_range.tz_offset_minutes == 0
if is_utc:
start_utc, end_utc = time_range.to_utc_datetime_range()
rows = (
context.db.query(StatsDaily)
.filter(StatsDaily.date >= start_utc, StatsDaily.date < end_utc)
.order_by(StatsDaily.date.asc())
.all()
)
result = []
for row in rows:
date_str = (
row.date.astimezone(timezone.utc).date().isoformat()
if row.date.tzinfo
else row.date.date().isoformat()
)
result.append(
{
"date": date_str,
"p50_response_time_ms": row.p50_response_time_ms,
"p90_response_time_ms": row.p90_response_time_ms,
"p99_response_time_ms": row.p99_response_time_ms,
"p50_first_byte_time_ms": row.p50_first_byte_time_ms,
"p90_first_byte_time_ms": row.p90_first_byte_time_ms,
"p99_first_byte_time_ms": row.p99_first_byte_time_ms,
}
)
return result
result = []
for local_date, day_start_utc, day_end_utc in time_range.get_local_day_hours():
percentiles = StatsAggregatorService.compute_daily_percentiles(
context.db, day_start_utc, day_end_utc
)
result.append({"date": local_date.isoformat(), **percentiles})
return result
@router.get("/performance/percentiles")
async def get_percentiles(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
adapter = AdminPercentilesAdapter(time_range=time_range)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,83 @@
"""Admin quota usage stats routes."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.core.enums import ProviderBillingType
from src.database import get_db
from src.models.database import Provider
from .common import pipeline
router = APIRouter()
class AdminQuotaUsageAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
providers = (
db.query(Provider)
.filter(
(Provider.billing_type == ProviderBillingType.MONTHLY_QUOTA)
| (Provider.monthly_quota_usd.isnot(None))
)
.all()
)
now = datetime.now(timezone.utc)
result = []
for provider in providers:
quota = provider.monthly_quota_usd or 0.0
used = float(provider.monthly_used_usd or 0.0)
remaining = max(quota - used, 0.0)
usage_percent = round((used / quota) * 100, 2) if quota > 0 else 0.0
reset_at = provider.quota_last_reset_at
if reset_at:
days_elapsed = max(1, (now - reset_at).days)
else:
days_elapsed = max(1, now.day - 1)
daily_rate = used / days_elapsed if used > 0 else 0.0
estimated_exhaust_at = None
if daily_rate > 0 and remaining > 0:
estimated_exhaust_at = now + timedelta(days=remaining / daily_rate)
if provider.quota_expires_at:
if not estimated_exhaust_at or provider.quota_expires_at < estimated_exhaust_at:
estimated_exhaust_at = provider.quota_expires_at
result.append(
{
"id": provider.id,
"name": provider.name,
"quota_usd": float(quota),
"used_usd": float(used),
"remaining_usd": float(remaining),
"usage_percent": usage_percent,
"quota_expires_at": (
provider.quota_expires_at.isoformat() if provider.quota_expires_at else None
),
"estimated_exhaust_at": (
estimated_exhaust_at.isoformat() if estimated_exhaust_at else None
),
}
)
result.sort(key=lambda x: x["usage_percent"], reverse=True)
return {"providers": result}
@router.get("/providers/quota-usage")
async def get_quota_usage(
request: Request,
db: Session = Depends(get_db),
) -> Any:
adapter = AdminQuotaUsageAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -0,0 +1,5 @@
"""Admin stats routes (compat export)."""
from . import router
__all__ = ["router"]

View File

@@ -0,0 +1,75 @@
"""Admin time series stats routes."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.database import get_db
from src.services.system.stats_aggregator import TimeSeriesFilter, query_time_series
from src.services.system.time_range import TimeRangeParams
from .common import _apply_admin_default_range, _build_time_range_params, pipeline
router = APIRouter()
class AdminTimeSeriesAdapter(AdminApiAdapter):
def __init__(
self,
time_range: TimeRangeParams | None,
user_id: str | None,
model: str | None,
provider_name: str | None,
) -> None:
self.time_range = _apply_admin_default_range(time_range)
self.user_id = user_id
self.model = model
self.provider_name = provider_name
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range:
return []
try:
return query_time_series(
context.db,
self.time_range,
filters=TimeSeriesFilter(
user_id=self.user_id, model=self.model, provider_name=self.provider_name
),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/time-series")
async def get_time_series(
request: Request,
db: Session = Depends(get_db),
start_date: date | None = Query(None),
end_date: date | None = Query(None),
preset: str | None = Query(None),
granularity: Literal["hour", "day", "week", "month"] = Query("day"),
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = Query(0),
user_id: str | None = Query(None),
model: str | None = Query(None),
provider_name: str | None = Query(None),
) -> Any:
time_range = _build_time_range_params(
start_date, end_date, preset, timezone_name, tz_offset_minutes
)
if time_range:
time_range.granularity = granularity
adapter = AdminTimeSeriesAdapter(
time_range=time_range,
user_id=user_id,
model=model,
provider_name=provider_name,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)

View File

@@ -4,11 +4,11 @@ from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy import case, func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
@@ -26,6 +26,8 @@ from src.models.database import (
Usage,
User,
)
from src.services.system.stats_aggregator import AggregatedStats, StatsFilter, query_stats_hybrid
from src.services.system.time_range import TimeRangeParams
from src.services.usage.service import UsageService
from src.utils.cache_decorator import cache_result
@@ -34,22 +36,45 @@ pipeline = ApiRequestPipeline()
def _apply_admin_default_range(
start_date: datetime | None, end_date: datetime | None
) -> tuple[datetime | None, datetime | None]:
"""
Apply a default time range for admin usage endpoints to protect DB from unbounded scans.
Enabled by setting ADMIN_USAGE_DEFAULT_DAYS>0.
"""
if start_date is not None or end_date is not None:
return start_date, end_date
params: TimeRangeParams | None,
) -> TimeRangeParams | None:
"""Apply a default range to avoid unbounded scans."""
if params is not None:
return params
days = int(getattr(config, "admin_usage_default_days", 0) or 0)
if days <= 0:
return start_date, end_date
return None
now = datetime.now(timezone.utc)
return now - timedelta(days=days), now
today = datetime.now(timezone.utc).date()
start_date = today - timedelta(days=days - 1)
return TimeRangeParams(
start_date=start_date,
end_date=today,
timezone="UTC",
tz_offset_minutes=0,
).validate_and_resolve()
def _build_time_range_params(
start_date: date | None,
end_date: date | None,
preset: str | None,
timezone_name: str | None,
tz_offset_minutes: int | None,
) -> TimeRangeParams | None:
if not preset and start_date is None and end_date is None:
return None
try:
return TimeRangeParams(
start_date=start_date,
end_date=end_date,
preset=preset,
timezone=timezone_name,
tz_offset_minutes=tz_offset_minutes or 0,
).validate_and_resolve()
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# ==================== RESTful Routes ====================
@@ -61,8 +86,11 @@ async def get_usage_aggregation(
group_by: str = Query(
..., description="Aggregation dimension: model, user, provider, or api_format"
),
start_date: datetime | None = None,
end_date: datetime | None = None,
start_date: date | None = None,
end_date: date | None = None,
preset: str | None = None,
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = None,
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
) -> Any:
@@ -83,16 +111,18 @@ async def get_usage_aggregation(
- 按提供商聚合时provider_id, provider, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms, success_rate, error_count
- 按 API 格式聚合时api_format, request_count, total_tokens, total_cost, actual_cost, avg_response_time_ms
"""
time_range = _apply_admin_default_range(
_build_time_range_params(start_date, end_date, preset, timezone_name, tz_offset_minutes)
)
if group_by == "model":
adapter = AdminUsageByModelAdapter(start_date=start_date, end_date=end_date, limit=limit)
adapter = AdminUsageByModelAdapter(time_range=time_range, limit=limit)
elif group_by == "user":
adapter = AdminUsageByUserAdapter(start_date=start_date, end_date=end_date, limit=limit)
adapter = AdminUsageByUserAdapter(time_range=time_range, limit=limit)
elif group_by == "provider":
adapter = AdminUsageByProviderAdapter(start_date=start_date, end_date=end_date, limit=limit)
adapter = AdminUsageByProviderAdapter(time_range=time_range, limit=limit)
elif group_by == "api_format":
adapter = AdminUsageByApiFormatAdapter(
start_date=start_date, end_date=end_date, limit=limit
)
adapter = AdminUsageByApiFormatAdapter(time_range=time_range, limit=limit)
else:
raise HTTPException(
status_code=400,
@@ -104,8 +134,11 @@ async def get_usage_aggregation(
@router.get("/stats")
async def get_usage_stats(
request: Request,
start_date: datetime | None = None,
end_date: datetime | None = None,
start_date: date | None = None,
end_date: date | None = None,
preset: str | None = None,
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = None,
db: Session = Depends(get_db),
) -> Any:
"""
@@ -127,7 +160,10 @@ async def get_usage_stats(
- `error_rate`: 错误率(百分比)
- `cache_stats`: 缓存统计信息cache_creation_tokens, cache_read_tokens, cache_creation_cost, cache_read_cost
"""
adapter = AdminUsageStatsAdapter(start_date=start_date, end_date=end_date)
time_range = _apply_admin_default_range(
_build_time_range_params(start_date, end_date, preset, timezone_name, tz_offset_minutes)
)
adapter = AdminUsageStatsAdapter(time_range=time_range)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -151,13 +187,17 @@ async def get_activity_heatmap(
@router.get("/records")
async def get_usage_records(
request: Request,
start_date: datetime | None = None,
end_date: datetime | None = None,
start_date: date | None = None,
end_date: date | None = None,
preset: str | None = None,
timezone_name: str | None = Query(None, alias="timezone"),
tz_offset_minutes: int | None = None,
search: str | None = None, # 通用搜索:用户名、密钥名、模型名、提供商名
user_id: str | None = None,
username: str | None = None,
model: str | None = None,
provider: str | None = None,
api_format: str | None = None, # API 格式筛选(如 openai:chat, claude:chat
status: str | None = None, # stream, standard, error
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
@@ -176,6 +216,7 @@ async def get_usage_records(
- `username`: 可选,用户名模糊搜索
- `model`: 可选,模型名模糊搜索
- `provider`: 可选,提供商名称搜索
- `api_format`: 可选API 格式筛选(如 openai:chat, claude:chat
- `status`: 可选状态筛选stream: 流式请求standard: 标准请求error: 错误请求pending: 等待中streaming: 流式中completed: 已完成failed: 失败active: 活跃请求)
- `limit`: 返回数量限制,默认 100最大 500
- `offset`: 分页偏移量,默认 0
@@ -190,14 +231,17 @@ async def get_usage_records(
- `limit`: 当前分页限制
- `offset`: 当前分页偏移量
"""
time_range = _apply_admin_default_range(
_build_time_range_params(start_date, end_date, preset, timezone_name, tz_offset_minutes)
)
adapter = AdminUsageRecordsAdapter(
start_date=start_date,
end_date=end_date,
time_range=time_range,
search=search,
user_id=user_id,
username=username,
model=model,
provider=provider,
api_format=api_format,
status=status,
limit=limit,
offset=offset,
@@ -288,73 +332,87 @@ async def get_usage_detail(
class AdminUsageStatsAdapter(AdminApiAdapter):
def __init__(self, start_date: datetime | None, end_date: datetime | None):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
def __init__(self, time_range: TimeRangeParams | None):
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
@cache_result(
key_prefix="admin:usage:stats",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["start_date", "end_date"],
vary_by=["start_date", "end_date", "preset", "timezone", "tz_offset_minutes"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
# Perf: use a single aggregate query (avoid 3 full scans).
from sqlalchemy import case
db = context.db
query = db.query(Usage)
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
stats = query.with_entities(
func.count(Usage.id).label("total_requests"),
func.sum(Usage.total_tokens).label("total_tokens"),
func.sum(Usage.total_cost_usd).label("total_cost"),
func.sum(Usage.actual_total_cost_usd).label("total_actual_cost"),
func.avg(Usage.response_time_ms).label("avg_response_time_ms"),
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
func.sum(
case(
(
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
1,
),
else_=0,
)
).label("error_count"),
).first()
if self.time_range:
stats = query_stats_hybrid(db, self.time_range, filters=StatsFilter())
else:
error_cond = (Usage.status_code >= 400) | (Usage.error_message.isnot(None))
row = db.query(
func.count(Usage.id).label("total_requests"),
func.sum(case((error_cond, 1), else_=0)).label("error_requests"),
func.sum(Usage.input_tokens).label("input_tokens"),
func.sum(Usage.output_tokens).label("output_tokens"),
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
func.sum(Usage.total_cost_usd).label("total_cost"),
func.sum(Usage.actual_total_cost_usd).label("actual_total_cost"),
func.sum(Usage.response_time_ms).label("total_response_time_ms"),
).first()
total_requests = int(getattr(row, "total_requests", 0) or 0)
error_requests = int(getattr(row, "error_requests", 0) or 0)
stats = AggregatedStats(
total_requests=total_requests,
success_requests=total_requests - error_requests,
error_requests=error_requests,
input_tokens=int(getattr(row, "input_tokens", 0) or 0),
output_tokens=int(getattr(row, "output_tokens", 0) or 0),
cache_creation_tokens=int(getattr(row, "cache_creation_tokens", 0) or 0),
cache_read_tokens=int(getattr(row, "cache_read_tokens", 0) or 0),
cache_creation_cost=float(getattr(row, "cache_creation_cost", 0) or 0.0),
cache_read_cost=float(getattr(row, "cache_read_cost", 0) or 0.0),
total_cost=float(getattr(row, "total_cost", 0) or 0.0),
actual_total_cost=float(getattr(row, "actual_total_cost", 0) or 0.0),
total_response_time_ms=float(getattr(row, "total_response_time_ms", 0) or 0.0),
)
context.add_audit_metadata(
action="usage_stats",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
)
total_requests = int(stats.total_requests or 0) if stats else 0
avg_response_time_ms = float(stats.avg_response_time_ms or 0) if stats else 0
avg_response_time = avg_response_time_ms / 1000.0
error_count = int(stats.error_count or 0) if stats else 0
total_requests = stats.total_requests
avg_response_time = stats.avg_response_time_ms / 1000.0
error_count = stats.error_requests
return {
"total_requests": total_requests,
"total_tokens": int(stats.total_tokens or 0) if stats else 0,
"total_cost": float(stats.total_cost or 0) if stats else 0,
"total_actual_cost": float(stats.total_actual_cost or 0) if stats else 0,
"total_tokens": int(
stats.input_tokens
+ stats.output_tokens
+ stats.cache_creation_tokens
+ stats.cache_read_tokens
),
"total_cost": float(stats.total_cost),
"total_actual_cost": float(stats.actual_total_cost),
"avg_response_time": round(avg_response_time, 2),
"error_count": error_count,
"error_rate": (
round((error_count / total_requests) * 100, 2) if total_requests > 0 else 0
),
"cache_stats": {
"cache_creation_tokens": (int(stats.cache_creation_tokens or 0) if stats else 0),
"cache_read_tokens": int(stats.cache_read_tokens or 0) if stats else 0,
"cache_creation_cost": (float(stats.cache_creation_cost or 0) if stats else 0),
"cache_read_cost": float(stats.cache_read_cost or 0) if stats else 0,
"cache_creation_tokens": int(stats.cache_creation_tokens),
"cache_read_tokens": int(stats.cache_read_tokens),
"cache_creation_cost": float(stats.cache_creation_cost),
"cache_read_cost": float(stats.cache_read_cost),
},
}
@@ -373,15 +431,20 @@ class AdminActivityHeatmapAdapter(AdminApiAdapter):
class AdminUsageByModelAdapter(AdminApiAdapter):
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
def __init__(self, time_range: TimeRangeParams | None, limit: int):
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
self.limit = limit
@cache_result(
key_prefix="admin:usage:agg:model",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["start_date", "end_date", "limit"],
vary_by=["start_date", "end_date", "preset", "timezone", "tz_offset_minutes", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
@@ -397,10 +460,9 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
# 过滤掉 unknown/pending provider_name请求未到达任何提供商
query = query.filter(Usage.provider_name.notin_(["unknown", "pending"]))
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
query = query.group_by(Usage.model).order_by(func.count(Usage.id).desc()).limit(self.limit)
stats = query.all()
@@ -408,6 +470,8 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
action="usage_by_model",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
limit=self.limit,
result_count=len(stats),
)
@@ -425,15 +489,20 @@ class AdminUsageByModelAdapter(AdminApiAdapter):
class AdminUsageByUserAdapter(AdminApiAdapter):
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
def __init__(self, time_range: TimeRangeParams | None, limit: int):
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
self.limit = limit
@cache_result(
key_prefix="admin:usage:agg:user",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["start_date", "end_date", "limit"],
vary_by=["start_date", "end_date", "preset", "timezone", "tz_offset_minutes", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
@@ -450,10 +519,9 @@ class AdminUsageByUserAdapter(AdminApiAdapter):
.group_by(User.id, User.email, User.username)
)
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
query = query.order_by(func.count(Usage.id).desc()).limit(self.limit)
stats = query.all()
@@ -462,6 +530,8 @@ class AdminUsageByUserAdapter(AdminApiAdapter):
action="usage_by_user",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
limit=self.limit,
result_count=len(stats),
)
@@ -480,15 +550,20 @@ class AdminUsageByUserAdapter(AdminApiAdapter):
class AdminUsageByProviderAdapter(AdminApiAdapter):
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
def __init__(self, time_range: TimeRangeParams | None, limit: int):
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
self.limit = limit
@cache_result(
key_prefix="admin:usage:agg:provider",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["start_date", "end_date", "limit"],
vary_by=["start_date", "end_date", "preset", "timezone", "tz_offset_minutes", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
@@ -511,10 +586,12 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
RequestCandidate.status.in_(["success", "failed"]),
)
if self.start_date:
attempt_query = attempt_query.filter(RequestCandidate.created_at >= self.start_date)
if self.end_date:
attempt_query = attempt_query.filter(RequestCandidate.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
attempt_query = attempt_query.filter(
RequestCandidate.created_at >= start_utc,
RequestCandidate.created_at < end_utc,
)
attempt_stats = (
attempt_query.group_by(RequestCandidate.provider_id)
@@ -537,10 +614,11 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
Usage.status.notin_(["pending", "streaming"]),
)
if self.start_date:
usage_query = usage_query.filter(Usage.created_at >= self.start_date)
if self.end_date:
usage_query = usage_query.filter(Usage.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
usage_query = usage_query.filter(
Usage.created_at >= start_utc, Usage.created_at < end_utc
)
usage_stats = usage_query.group_by(Usage.provider_id).all()
usage_map = {str(u.provider_id): u for u in usage_stats}
@@ -566,6 +644,8 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
action="usage_by_provider",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
limit=self.limit,
result_count=len(attempt_stats),
)
@@ -599,15 +679,20 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
class AdminUsageByApiFormatAdapter(AdminApiAdapter):
def __init__(self, start_date: datetime | None, end_date: datetime | None, limit: int):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
def __init__(self, time_range: TimeRangeParams | None, limit: int):
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
self.limit = limit
@cache_result(
key_prefix="admin:usage:agg:api_format",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["start_date", "end_date", "limit"],
vary_by=["start_date", "end_date", "preset", "timezone", "tz_offset_minutes", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
@@ -626,10 +711,9 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
# 只统计有 api_format 的记录
query = query.filter(Usage.api_format.isnot(None))
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
query = (
query.group_by(Usage.api_format).order_by(func.count(Usage.id).desc()).limit(self.limit)
@@ -640,6 +724,8 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
action="usage_by_api_format",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
limit=self.limit,
result_count=len(stats),
)
@@ -660,23 +746,29 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
class AdminUsageRecordsAdapter(AdminApiAdapter):
def __init__(
self,
start_date: datetime | None,
end_date: datetime | None,
time_range: TimeRangeParams | None,
search: str | None,
user_id: str | None,
username: str | None,
model: str | None,
provider: str | None,
api_format: str | None,
status: str | None,
limit: int,
offset: int,
):
self.start_date, self.end_date = _apply_admin_default_range(start_date, end_date)
self.time_range = _apply_admin_default_range(time_range)
self.start_date = self.time_range.start_date if self.time_range else None
self.end_date = self.time_range.end_date if self.time_range else None
self.preset = self.time_range.preset if self.time_range else None
self.timezone = self.time_range.timezone if self.time_range else None
self.tz_offset_minutes = self.time_range.tz_offset_minutes if self.time_range else None
self.search = search
self.user_id = user_id
self.username = username
self.model = model
self.provider = provider
self.api_format = api_format
self.status = status
self.limit = limit
self.offset = offset
@@ -688,11 +780,15 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
vary_by=[
"start_date",
"end_date",
"preset",
"timezone",
"tz_offset_minutes",
"search",
"user_id",
"username",
"model",
"provider",
"api_format",
"status",
"limit",
"offset",
@@ -748,6 +844,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
# 提供商筛选:前端为下拉框精确值,使用精确匹配以启用索引
# 如需模糊搜索,请使用 search 参数。
query = query.filter(Provider.name == self.provider)
if self.api_format:
# API 格式筛选:精确匹配(大小写不敏感)
query = query.filter(func.lower(Usage.api_format) == self.api_format.lower())
if self.status:
# 状态筛选
# 旧的筛选值(基于 is_stream 和 status_codestream, standard, error
@@ -773,10 +872,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
elif self.status == "active":
# 活跃请求pending 或 streaming 状态
query = query.filter(Usage.status.in_(["pending", "streaming"]))
if self.start_date:
query = query.filter(Usage.created_at >= self.start_date)
if self.end_date:
query = query.filter(Usage.created_at <= self.end_date)
if self.time_range:
start_utc, end_utc = self.time_range.to_utc_datetime_range()
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
# Perf: avoid Query.count() building a subquery selecting many columns
total = int(query.with_entities(func.count(Usage.id)).scalar() or 0)
@@ -875,6 +973,8 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
action="usage_records",
start_date=self.start_date.isoformat() if self.start_date else None,
end_date=self.end_date.isoformat() if self.end_date else None,
preset=self.preset,
timezone=self.timezone,
search=self.search,
user_id=self.user_id,
username=self.username,