mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat: 统计数据优化 - 支持细粒度时间范围和多维度分析
- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计 - 实现 UTC datetime 范围查询策略,边界数据实时聚合 - 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类 - 新增前端页面:成本分析、性能分析、用户统计 - 新增 TimeRangePicker 组件和统计可视化组件 - 优化 Dashboard 和 Usage 页面支持时间范围筛选 Close #135
This commit is contained in:
@@ -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)
|
||||
|
||||
22
src/api/admin/stats/__init__.py
Normal file
22
src/api/admin/stats/__init__.py
Normal 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"]
|
||||
196
src/api/admin/stats/common.py
Normal file
196
src/api/admin/stats/common.py
Normal 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()
|
||||
128
src/api/admin/stats/comparison.py
Normal file
128
src/api/admin/stats/comparison.py
Normal 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
183
src/api/admin/stats/cost.py
Normal 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)
|
||||
111
src/api/admin/stats/errors.py
Normal file
111
src/api/admin/stats/errors.py
Normal 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)
|
||||
759
src/api/admin/stats/leaderboard.py
Normal file
759
src/api/admin/stats/leaderboard.py
Normal 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)
|
||||
85
src/api/admin/stats/performance.py
Normal file
85
src/api/admin/stats/performance.py
Normal 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)
|
||||
83
src/api/admin/stats/quota.py
Normal file
83
src/api/admin/stats/quota.py
Normal 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)
|
||||
5
src/api/admin/stats/routes.py
Normal file
5
src/api/admin/stats/routes.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Admin stats routes (compat export)."""
|
||||
|
||||
from . import router
|
||||
|
||||
__all__ = ["router"]
|
||||
75
src/api/admin/stats/time_series.py
Normal file
75
src/api/admin/stats/time_series.py
Normal 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)
|
||||
@@ -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_code):stream, 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,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -27,7 +27,12 @@ from src.models.database import (
|
||||
Usage,
|
||||
)
|
||||
from src.models.database import User as DBUser
|
||||
from src.services.system.stats_aggregator import StatsAggregatorService
|
||||
from src.services.system.stats_aggregator import (
|
||||
StatsAggregatorService,
|
||||
TimeSeriesFilter,
|
||||
query_time_series,
|
||||
)
|
||||
from src.services.system.time_range import TimeRangeParams
|
||||
from src.utils.cache_decorator import cache_result
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
|
||||
@@ -55,6 +60,29 @@ def format_tokens(num: int) -> str:
|
||||
return f"{millions:.2f}M"
|
||||
|
||||
|
||||
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,
|
||||
granularity: str | None = 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,
|
||||
granularity=granularity or "day",
|
||||
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
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_dashboard_stats(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
@@ -136,6 +164,12 @@ async def get_provider_status(request: Request, db: Session = Depends(get_db)) -
|
||||
async def get_daily_stats(
|
||||
request: Request,
|
||||
days: int = Query(7, ge=1, le=30),
|
||||
start_date: date | None = Query(None, description="开始日期(YYYY-MM-DD)"),
|
||||
end_date: date | None = Query(None, description="结束日期(YYYY-MM-DD)"),
|
||||
preset: str | None = Query(None, description="时间预设(today/last7days 等)"),
|
||||
granularity: str = Query("day", description="时间粒度: hour/day/week/month"),
|
||||
timezone_name: str | None = Query(None, alias="timezone"),
|
||||
tz_offset_minutes: int | None = Query(None, description="时区偏移(分钟)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
@@ -160,7 +194,30 @@ async def get_daily_stats(
|
||||
- `model_summary`: 模型使用汇总,按费用排序
|
||||
- `period`: 统计周期信息(start_date, end_date, days)
|
||||
"""
|
||||
adapter = DashboardDailyStatsAdapter(days=days)
|
||||
time_range = _build_time_range_params(
|
||||
start_date, end_date, preset, timezone_name, tz_offset_minutes, granularity
|
||||
)
|
||||
if time_range is None:
|
||||
# fallback to days
|
||||
tmp = TimeRangeParams(
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
preset="today",
|
||||
granularity=granularity,
|
||||
timezone=timezone_name,
|
||||
tz_offset_minutes=tz_offset_minutes or 0,
|
||||
)
|
||||
user_today = tmp._get_user_today()
|
||||
start = user_today - timedelta(days=days - 1)
|
||||
time_range = TimeRangeParams(
|
||||
start_date=start,
|
||||
end_date=user_today,
|
||||
granularity=granularity,
|
||||
timezone=timezone_name,
|
||||
tz_offset_minutes=tz_offset_minutes or 0,
|
||||
).validate_and_resolve()
|
||||
|
||||
adapter = DashboardDailyStatsAdapter(time_range=time_range, days=days)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@@ -194,21 +251,12 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
||||
)
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
"""管理员仪表盘统计 - 使用预聚合数据优化性能"""
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.services.system.stats_aggregator import APP_TIMEZONE
|
||||
|
||||
db = context.db
|
||||
# 使用业务时区计算日期,与 stats_daily 表保持一致
|
||||
app_tz = ZoneInfo(APP_TIMEZONE)
|
||||
now_local = datetime.now(app_tz)
|
||||
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
|
||||
today = today_local.astimezone(timezone.utc)
|
||||
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
||||
# 本月第一天(自然月)
|
||||
month_start_local = today_local.replace(day=1)
|
||||
month_start = month_start_local.astimezone(timezone.utc)
|
||||
# 使用 UTC 日期,与 stats_daily.date 一致
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday = today - timedelta(days=1)
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
# ==================== 使用预聚合数据 ====================
|
||||
# 今日实时数据只查询一次,避免重复扫描 Usage 表
|
||||
@@ -887,20 +935,169 @@ class DashboardProviderStatusAdapter(DashboardAdapter):
|
||||
@dataclass
|
||||
class DashboardDailyStatsAdapter(DashboardAdapter):
|
||||
days: int
|
||||
time_range: TimeRangeParams | None = None
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
preset: str | None = None
|
||||
granularity: str | None = None
|
||||
timezone: str | None = None
|
||||
tz_offset_minutes: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.time_range:
|
||||
self.start_date = self.time_range.start_date
|
||||
self.end_date = self.time_range.end_date
|
||||
self.preset = self.time_range.preset
|
||||
self.granularity = self.time_range.granularity
|
||||
self.timezone = self.time_range.timezone
|
||||
self.tz_offset_minutes = self.time_range.tz_offset_minutes
|
||||
|
||||
@cache_result(
|
||||
key_prefix="dashboard:daily:stats", ttl=CacheTTL.DASHBOARD_DAILY, user_specific=True
|
||||
key_prefix="dashboard:daily:stats",
|
||||
ttl=CacheTTL.DASHBOARD_DAILY,
|
||||
user_specific=True,
|
||||
vary_by=[
|
||||
"start_date",
|
||||
"end_date",
|
||||
"preset",
|
||||
"granularity",
|
||||
"timezone",
|
||||
"tz_offset_minutes",
|
||||
],
|
||||
)
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.services.system.stats_aggregator import APP_TIMEZONE
|
||||
|
||||
db = context.db
|
||||
user = context.user
|
||||
is_admin = user.role == UserRole.ADMIN
|
||||
|
||||
if self.time_range:
|
||||
try:
|
||||
series = query_time_series(
|
||||
db,
|
||||
self.time_range,
|
||||
filters=TimeSeriesFilter(user_id=user.id) if not is_admin else None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
formatted = []
|
||||
for item in series:
|
||||
total_tokens = (
|
||||
item["input_tokens"]
|
||||
+ item["output_tokens"]
|
||||
+ item.get("cache_creation_tokens", 0)
|
||||
+ item.get("cache_read_tokens", 0)
|
||||
)
|
||||
formatted.append(
|
||||
{
|
||||
"date": item["date"],
|
||||
"requests": item["total_requests"],
|
||||
"tokens": total_tokens,
|
||||
"cost": item["total_cost"],
|
||||
"avg_response_time": (item.get("avg_response_time_ms", 0.0) / 1000.0),
|
||||
"unique_models": 0,
|
||||
"unique_providers": 0,
|
||||
"fallback_count": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Model summary (use Usage directly for now)
|
||||
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
||||
model_query = db.query(
|
||||
Usage.model,
|
||||
func.count(Usage.id).label("requests"),
|
||||
func.sum(Usage.total_tokens).label("tokens"),
|
||||
func.sum(Usage.total_cost_usd).label("cost"),
|
||||
).filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
||||
if not is_admin:
|
||||
model_query = model_query.filter(Usage.user_id == user.id)
|
||||
model_stats = (
|
||||
model_query.group_by(Usage.model)
|
||||
.order_by(func.sum(Usage.total_cost_usd).desc())
|
||||
.all()
|
||||
)
|
||||
model_summary = [
|
||||
{
|
||||
"model": stat.model,
|
||||
"requests": stat.requests or 0,
|
||||
"tokens": int(stat.tokens or 0),
|
||||
"cost": float(stat.cost or 0),
|
||||
"avg_response_time": 0,
|
||||
"cost_per_request": float(stat.cost or 0) / max(stat.requests or 1, 1),
|
||||
"tokens_per_request": int(stat.tokens or 0) / max(stat.requests or 1, 1),
|
||||
}
|
||||
for stat in model_stats
|
||||
]
|
||||
|
||||
# Daily model breakdown (aligned to local days)
|
||||
breakdown_map: dict[str, list[dict]] = {}
|
||||
for local_date, day_start, day_end in self.time_range.get_local_day_hours():
|
||||
day_query = db.query(
|
||||
Usage.model,
|
||||
func.count(Usage.id).label("requests"),
|
||||
func.sum(Usage.total_tokens).label("tokens"),
|
||||
func.sum(Usage.total_cost_usd).label("cost"),
|
||||
).filter(Usage.created_at >= day_start, Usage.created_at < day_end)
|
||||
if not is_admin:
|
||||
day_query = day_query.filter(Usage.user_id == user.id)
|
||||
day_stats = day_query.group_by(Usage.model).all()
|
||||
breakdown_map[local_date.isoformat()] = [
|
||||
{
|
||||
"model": stat.model,
|
||||
"requests": stat.requests or 0,
|
||||
"tokens": int(stat.tokens or 0),
|
||||
"cost": float(stat.cost or 0),
|
||||
}
|
||||
for stat in day_stats
|
||||
if stat.model
|
||||
]
|
||||
|
||||
for item in formatted:
|
||||
item["model_breakdown"] = breakdown_map.get(item["date"], [])
|
||||
|
||||
provider_summary = None
|
||||
if is_admin:
|
||||
provider_stats = (
|
||||
db.query(
|
||||
Usage.provider_name,
|
||||
func.count(Usage.id).label("requests"),
|
||||
func.sum(Usage.total_tokens).label("tokens"),
|
||||
func.sum(Usage.total_cost_usd).label("cost"),
|
||||
)
|
||||
.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
||||
.group_by(Usage.provider_name)
|
||||
.all()
|
||||
)
|
||||
provider_summary = [
|
||||
{
|
||||
"provider": stat.provider_name or "Unknown",
|
||||
"requests": stat.requests or 0,
|
||||
"tokens": int(stat.tokens or 0),
|
||||
"cost": float(stat.cost or 0),
|
||||
}
|
||||
for stat in provider_stats
|
||||
if (stat.provider_name or "").lower() != "unknown"
|
||||
]
|
||||
provider_summary.sort(key=lambda x: x["cost"], reverse=True)
|
||||
|
||||
result = {
|
||||
"daily_stats": formatted,
|
||||
"model_summary": model_summary,
|
||||
"period": {
|
||||
"start_date": self.time_range.start_date.isoformat(),
|
||||
"end_date": self.time_range.end_date.isoformat(),
|
||||
"days": (self.time_range.end_date - self.time_range.start_date).days + 1,
|
||||
},
|
||||
}
|
||||
if is_admin and provider_summary is not None:
|
||||
result["provider_summary"] = provider_summary
|
||||
return result
|
||||
|
||||
# 使用业务时区计算日期,确保每日统计与业务日期一致
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.services.system.stats_aggregator import APP_TIMEZONE
|
||||
|
||||
app_tz = ZoneInfo(APP_TIMEZONE)
|
||||
now_local = datetime.now(app_tz)
|
||||
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
@@ -33,6 +33,7 @@ from src.models.api import (
|
||||
UpdateProfileRequest,
|
||||
)
|
||||
from src.models.database import ApiKey, GlobalModel, Model, Provider, Usage, User
|
||||
from src.services.system.time_range import TimeRangeParams
|
||||
from src.services.usage.service import UsageService
|
||||
from src.services.user.apikey import ApiKeyService
|
||||
from src.services.user.preference import PreferenceService
|
||||
@@ -41,6 +42,27 @@ router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_my_profile(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
@@ -176,8 +198,11 @@ async def toggle_my_api_key(key_id: str, request: Request, db: Session = Depends
|
||||
@router.get("/usage")
|
||||
async def get_my_usage(
|
||||
request: Request,
|
||||
start_date: datetime | None = Query(None, description="开始时间(ISO 格式)"),
|
||||
end_date: datetime | None = Query(None, description="结束时间(ISO 格式)"),
|
||||
start_date: date | None = Query(None, description="开始日期(YYYY-MM-DD)"),
|
||||
end_date: date | None = Query(None, description="结束日期(YYYY-MM-DD)"),
|
||||
preset: str | None = Query(None, description="时间预设(today/last7days 等)"),
|
||||
timezone_name: str | None = Query(None, alias="timezone"),
|
||||
tz_offset_minutes: int | None = Query(None, description="时区偏移(分钟)"),
|
||||
search: str | None = Query(None, description="搜索关键词(密钥名、模型名)"),
|
||||
limit: int = Query(100, ge=1, le=200, description="每页记录数,默认100,最大200"),
|
||||
offset: int = Query(0, ge=0, le=2000, description="偏移量,用于分页,最大2000"),
|
||||
@@ -197,9 +222,10 @@ async def get_my_usage(
|
||||
- `records`: 详细使用记录列表
|
||||
- `pagination`: 分页信息
|
||||
"""
|
||||
adapter = GetUsageAdapter(
|
||||
start_date=start_date, end_date=end_date, search=search, limit=limit, offset=offset
|
||||
time_range = _build_time_range_params(
|
||||
start_date, end_date, preset, timezone_name, tz_offset_minutes
|
||||
)
|
||||
adapter = GetUsageAdapter(time_range=time_range, search=search, limit=limit, offset=offset)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@@ -728,8 +754,7 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
|
||||
class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||
"""获取用户使用统计的适配器"""
|
||||
|
||||
start_date: datetime | None
|
||||
end_date: datetime | None
|
||||
time_range: TimeRangeParams | None
|
||||
search: str | None = None
|
||||
limit: int = 100
|
||||
offset: int = 0
|
||||
@@ -743,11 +768,14 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||
|
||||
db = context.db
|
||||
user = context.user
|
||||
start_utc = end_utc = None
|
||||
if self.time_range:
|
||||
start_utc, end_utc = self.time_range.to_utc_datetime_range()
|
||||
summary_list = UsageService.get_usage_summary(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
start_date=self.start_date,
|
||||
end_date=self.end_date,
|
||||
start_date=start_utc,
|
||||
end_date=end_utc,
|
||||
)
|
||||
|
||||
# 过滤掉 unknown/pending provider 的记录(请求未到达任何提供商)
|
||||
@@ -858,10 +886,8 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
||||
.filter(Usage.user_id == user.id)
|
||||
)
|
||||
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 start_utc and end_utc:
|
||||
query = query.filter(Usage.created_at >= start_utc, Usage.created_at < end_utc)
|
||||
|
||||
# 通用搜索:密钥名、模型名
|
||||
# 支持空格分隔的组合搜索,多个关键词之间是 AND 关系
|
||||
@@ -925,10 +951,10 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
||||
Usage.status_code == 200,
|
||||
Usage.response_time_ms.isnot(None),
|
||||
)
|
||||
if self.start_date:
|
||||
avg_resp_query = avg_resp_query.filter(Usage.created_at >= self.start_date)
|
||||
if self.end_date:
|
||||
avg_resp_query = avg_resp_query.filter(Usage.created_at <= self.end_date)
|
||||
if start_utc and end_utc:
|
||||
avg_resp_query = avg_resp_query.filter(
|
||||
Usage.created_at >= start_utc, Usage.created_at < end_utc
|
||||
)
|
||||
avg_response_ms = avg_resp_query.scalar() or 0
|
||||
avg_response_time = float(avg_response_ms) / 1000.0 if avg_response_ms else 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user