refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

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 get_pipeline
from src.config.settings import config
from src.models.database import Usage
from src.services.system.time_range import TimeRangeParams
pipeline = get_pipeline()
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,142 @@
"""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.config.constants import CacheTTL
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 src.utils.cache_decorator import cache_result
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
@cache_result(
key_prefix="admin:stats:comparison",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"current_start",
"current_end",
"comparison_type",
"timezone_name",
"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)

View File

@@ -0,0 +1,215 @@
"""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.config.constants import CacheTTL
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 src.utils.cache_decorator import cache_result
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.fallback_tz_offset_minutes = tz_offset_minutes
@cache_result(
key_prefix="admin:stats:cost:forecast",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"days",
"forecast_days",
"timezone_name",
"fallback_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.fallback_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
@cache_result(
key_prefix="admin:stats:cost:savings",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"provider_name",
"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,125 @@
"""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.config.constants import CacheTTL
from src.database import get_db
from src.models.database import StatsDailyError, Usage
from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
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)
@cache_result(
key_prefix="admin:stats:errors:distribution",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
],
)
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,93 @@
"""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.config.constants import CacheTTL
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 src.utils.cache_decorator import cache_result
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)
@cache_result(
key_prefix="admin:stats:performance:percentiles",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
],
)
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
return StatsAggregatorService.compute_percentiles_by_local_day(context.db, time_range)
@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,90 @@
"""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.config.constants import CacheTTL
from src.core.enums import ProviderBillingType
from src.database import get_db
from src.models.database import Provider
from src.utils.cache_decorator import cache_result
from .common import pipeline
router = APIRouter()
class AdminQuotaUsageAdapter(AdminApiAdapter):
@cache_result(
key_prefix="admin:stats:providers:quota_usage",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
)
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 = float(provider.monthly_quota_usd or 0)
used = float(provider.monthly_used_usd or 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,93 @@
"""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.config.constants import CacheTTL
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 src.utils.cache_decorator import cache_result
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
@cache_result(
key_prefix="admin:stats:time_series",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"time_range.granularity",
"user_id",
"model",
"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)