"""仪表盘统计 API 端点。""" from __future__ import annotations from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import and_, case, func from sqlalchemy.orm import Session from src.api.base.adapter import ApiAdapter, ApiMode from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.context import ApiRequestContext from src.api.base.pipeline import get_pipeline from src.config.constants import CacheTTL from src.core.enums import UserRole from src.database import get_db from src.models.database import ( ApiKey, Provider, RequestCandidate, StatsDaily, StatsDailyModel, StatsDailyProvider, Usage, ) from src.models.database import User as DBUser from src.services.system.stats_aggregator import ( StatsAggregatorService, TimeSeriesFilter, query_time_series, ) from src.services.system.time_range import TimeRangeParams from src.services.wallet import WalletService from src.utils.cache_decorator import cache_result router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"]) pipeline = get_pipeline() def format_tokens(num: int) -> str: """格式化 Token 数量,自动转换 K/M 单位""" if num < 1000: return str(num) if num < 1000000: thousands = num / 1000 if thousands >= 100: return f"{round(thousands)}K" elif thousands >= 10: return f"{thousands:.1f}K" else: return f"{thousands:.2f}K" millions = num / 1000000 if millions >= 100: return f"{round(millions)}M" elif millions >= 10: return f"{millions:.1f}M" else: 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: """ 获取仪表盘统计数据 根据用户角色返回不同的统计数据。管理员可以看到全局数据,普通用户只能看到自己的数据。 **返回字段(管理员)**: - `stats`: 统计卡片数组,包含总请求、总费用、总Token、总缓存等信息 - `today`: 今日统计(requests, cost, actual_cost, tokens, cache_creation_tokens, cache_read_tokens) - `api_keys`: API Key 统计(total, active) - `tokens`: 本月 Token 统计 - `token_breakdown`: Token 详细分类(input, output, cache_creation, cache_read) - `system_health`: 系统健康指标(avg_response_time, error_rate, error_requests, fallback_count, total_requests) - `cost_stats`: 成本统计(total_cost, total_actual_cost, cost_savings) - `cache_stats`: 缓存统计信息 - `users`: 用户统计(total, active) **返回字段(普通用户)**: - `stats`: 统计卡片数组,包含 API 密钥、本月请求、钱包状态、总Token 等信息 - `today`: 今日统计 - `token_breakdown`: Token 详细分类 - `cache_stats`: 缓存统计信息 - `monthly_cost`: 本月费用 """ adapter = DashboardStatsAdapter() return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) @router.get("/recent-requests") async def get_recent_requests( request: Request, limit: int = Query(10, ge=1, le=100), db: Session = Depends(get_db), ) -> Any: """ 获取最近请求列表 获取最近的 API 请求记录。管理员可以看到所有用户的请求,普通用户只能看到自己的请求。 **查询参数**: - `limit`: 返回记录数,默认 10,最大 100 **返回字段**: - `requests`: 请求列表,每条记录包含: - `id`: 请求 ID - `user`: 用户名 - `model`: 使用的模型 - `tokens`: Token 数量 - `time`: 请求时间(HH:MM 格式) - `is_stream`: 是否为流式请求 """ adapter = DashboardRecentRequestsAdapter(limit=limit) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) # NOTE: /request-detail/{request_id} has been moved to /api/admin/usage/{id} # The old route is removed. Use dashboardApi.getRequestDetail() which now calls the new API. @router.get("/provider-status") async def get_provider_status(request: Request, db: Session = Depends(get_db)) -> Any: """ 获取提供商状态 获取所有活跃提供商的状态和最近 24 小时的请求统计。 **返回字段**: - `providers`: 提供商列表,每个提供商包含: - `name`: 提供商名称 - `status`: 状态(active/inactive) - `requests`: 最近 24 小时的请求数 """ adapter = DashboardProviderStatusAdapter() return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) @router.get("/daily-stats") 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: """ 获取每日统计数据 获取指定天数的每日使用统计数据,用于生成图表。 **查询参数**: - `days`: 统计天数,默认 7 天,最大 30 天 **返回字段**: - `daily_stats`: 每日统计数组,每天包含: - `date`: 日期(ISO 格式) - `requests`: 请求数 - `tokens`: Token 数量 - `cost`: 费用(USD) - `avg_response_time`: 平均响应时间(秒) - `unique_models`: 使用的模型数量(仅管理员) - `unique_providers`: 使用的提供商数量(仅管理员) - `fallback_count`: 故障转移次数(仅管理员) - `model_breakdown`: 按模型分解的统计(仅管理员) - `model_summary`: 模型使用汇总,按费用排序 - `period`: 统计周期信息(start_date, end_date, 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) class DashboardAdapter(ApiAdapter): """需要登录的仪表盘适配器基类。""" mode = ApiMode.USER # 普通用户也可访问仪表盘 def authorize(self, context: ApiRequestContext) -> None: # type: ignore[override] if not context.user: raise HTTPException(status_code=401, detail="未登录") class DashboardStatsAdapter(DashboardAdapter): async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] user = context.user if not user: raise HTTPException(status_code=401, detail="未登录") adapter = ( AdminDashboardStatsAdapter() if user.role == UserRole.ADMIN else UserDashboardStatsAdapter() ) return await adapter.handle(context) class AdminDashboardStatsAdapter(AdminApiAdapter): @cache_result( key_prefix="dashboard:admin:stats", ttl=CacheTTL.DASHBOARD_STATS, user_specific=False ) async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] """管理员仪表盘统计 - 使用预聚合数据优化性能""" db = context.db # 使用 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 表 today_stats = StatsAggregatorService.get_today_realtime_stats(db) # 从 stats_summary + 今日实时数据获取全局统计 combined_stats = StatsAggregatorService.get_combined_stats(db, today_stats=today_stats) all_time_requests = combined_stats["total_requests"] all_time_success_requests = combined_stats["success_requests"] all_time_error_requests = combined_stats["error_requests"] all_time_input_tokens = combined_stats["input_tokens"] all_time_output_tokens = combined_stats["output_tokens"] all_time_cache_creation = combined_stats["cache_creation_tokens"] all_time_cache_read = combined_stats["cache_read_tokens"] all_time_cost = combined_stats["total_cost"] all_time_actual_cost = combined_stats["actual_total_cost"] # 用户/API Key 统计 total_users = combined_stats.get("total_users") or db.query(func.count(DBUser.id)).scalar() active_users = combined_stats.get("active_users") or ( db.query(func.count(DBUser.id)).filter(DBUser.is_active.is_(True)).scalar() ) total_api_keys = ( combined_stats.get("total_api_keys") or db.query(func.count(ApiKey.id)).scalar() ) active_api_keys = combined_stats.get("active_api_keys") or ( db.query(func.count(ApiKey.id)).filter(ApiKey.is_active.is_(True)).scalar() ) # ==================== 今日实时统计 ==================== requests_today = today_stats["total_requests"] cost_today = today_stats["total_cost"] actual_cost_today = today_stats["actual_total_cost"] input_tokens_today = today_stats["input_tokens"] output_tokens_today = today_stats["output_tokens"] cache_creation_today = today_stats["cache_creation_tokens"] cache_read_today = today_stats["cache_read_tokens"] tokens_today = ( input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today ) # ==================== 昨日统计(从预聚合表获取)==================== yesterday_stats = db.query(StatsDaily).filter(StatsDaily.date == yesterday).first() if yesterday_stats: requests_yesterday = yesterday_stats.total_requests cost_yesterday = float(yesterday_stats.total_cost or 0) input_tokens_yesterday = yesterday_stats.input_tokens output_tokens_yesterday = yesterday_stats.output_tokens cache_creation_yesterday = yesterday_stats.cache_creation_tokens cache_read_yesterday = yesterday_stats.cache_read_tokens else: # 如果没有预聚合数据,回退到实时查询 requests_yesterday = ( db.query(func.count(Usage.id)) .filter(Usage.created_at >= yesterday, Usage.created_at < today) .scalar() or 0 ) cost_yesterday = ( db.query(func.sum(Usage.total_cost_usd)) .filter(Usage.created_at >= yesterday, Usage.created_at < today) .scalar() or 0 ) yesterday_token_stats = ( db.query( 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"), ) .filter(Usage.created_at >= yesterday, Usage.created_at < today) .first() ) input_tokens_yesterday = ( int(yesterday_token_stats.input_tokens or 0) if yesterday_token_stats else 0 ) output_tokens_yesterday = ( int(yesterday_token_stats.output_tokens or 0) if yesterday_token_stats else 0 ) cache_creation_yesterday = ( int(yesterday_token_stats.cache_creation_tokens or 0) if yesterday_token_stats else 0 ) cache_read_yesterday = ( int(yesterday_token_stats.cache_read_tokens or 0) if yesterday_token_stats else 0 ) # ==================== 本月统计(从预聚合表聚合)==================== monthly_stats = ( db.query( func.sum(StatsDaily.total_requests).label("total_requests"), func.sum(StatsDaily.error_requests).label("error_requests"), func.sum(StatsDaily.total_cost).label("total_cost"), func.sum(StatsDaily.actual_total_cost).label("actual_total_cost"), func.sum( StatsDaily.input_tokens + StatsDaily.output_tokens + StatsDaily.cache_creation_tokens + StatsDaily.cache_read_tokens ).label("total_tokens"), func.sum(StatsDaily.cache_creation_tokens).label("cache_creation_tokens"), func.sum(StatsDaily.cache_read_tokens).label("cache_read_tokens"), func.sum(StatsDaily.cache_creation_cost).label("cache_creation_cost"), func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"), func.sum(StatsDaily.fallback_count).label("fallback_count"), ) .filter(StatsDaily.date >= month_start, StatsDaily.date < today) .first() ) # 本月数据 = 预聚合月数据 + 今日实时数据 if monthly_stats and monthly_stats.total_requests: total_requests = int(monthly_stats.total_requests or 0) + requests_today error_requests = int(monthly_stats.error_requests or 0) + today_stats["error_requests"] total_cost = float(monthly_stats.total_cost or 0) + float(cost_today) total_actual_cost = float(monthly_stats.actual_total_cost or 0) + float( actual_cost_today ) total_tokens = int(monthly_stats.total_tokens or 0) + tokens_today cache_creation_tokens = ( int(monthly_stats.cache_creation_tokens or 0) + cache_creation_today ) cache_read_tokens = int(monthly_stats.cache_read_tokens or 0) + cache_read_today cache_creation_cost = float(monthly_stats.cache_creation_cost or 0) cache_read_cost = float(monthly_stats.cache_read_cost or 0) fallback_count = int(monthly_stats.fallback_count or 0) else: # 回退到实时查询(没有预聚合数据时) total_requests = ( db.query(func.count(Usage.id)).filter(Usage.created_at >= month_start).scalar() or 0 ) total_cost = ( db.query(func.sum(Usage.total_cost_usd)) .filter(Usage.created_at >= month_start) .scalar() or 0 ) total_actual_cost = ( db.query(func.sum(Usage.actual_total_cost_usd)) .filter(Usage.created_at >= month_start) .scalar() or 0 ) error_requests = ( db.query(func.count(Usage.id)) .filter( Usage.created_at >= month_start, (Usage.status_code >= 400) | (Usage.error_message.isnot(None)), ) .scalar() or 0 ) total_tokens = ( db.query(func.sum(Usage.total_tokens)) .filter(Usage.created_at >= month_start) .scalar() or 0 ) cache_stats = ( db.query( 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"), ) .filter(Usage.created_at >= month_start) .first() ) cache_creation_tokens = ( int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0 ) cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0 cache_creation_cost = float(cache_stats.cache_creation_cost or 0) if cache_stats else 0 cache_read_cost = float(cache_stats.cache_read_cost or 0) if cache_stats else 0 # Fallback 统计 fallback_subquery = ( db.query( RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count"), ) .filter( RequestCandidate.created_at >= month_start, RequestCandidate.status.in_(["success", "failed"]), ) .group_by(RequestCandidate.request_id) .subquery() ) fallback_count = ( db.query(func.count()) .select_from(fallback_subquery) .filter(fallback_subquery.c.executed_count > 1) .scalar() or 0 ) # ==================== 系统健康指标 ==================== error_rate = round((error_requests / total_requests) * 100, 2) if total_requests > 0 else 0 # 平均响应时间(仅查询今日数据,降低查询成本) avg_response_time = ( db.query(func.avg(Usage.response_time_ms)) .filter( Usage.created_at >= today, Usage.status_code == 200, Usage.response_time_ms.isnot(None), ) .scalar() or 0 ) avg_response_time_seconds = float(avg_response_time) / 1000.0 # 缓存命中率 total_input_with_cache = all_time_input_tokens + all_time_cache_read cache_hit_rate = ( round((all_time_cache_read / total_input_with_cache) * 100, 1) if total_input_with_cache > 0 else 0 ) return { "stats": [ { "name": "总请求", "value": f"{all_time_requests:,}", "subValue": f"有效 {all_time_success_requests:,} / 异常 {all_time_error_requests:,}", "change": ( f"+{requests_today}" if requests_today > requests_yesterday else str(requests_today) ), "changeType": ( "increase" if requests_today > requests_yesterday else ("decrease" if requests_today < requests_yesterday else "neutral") ), "icon": "Activity", }, { "name": "总费用", "value": f"${all_time_cost:.2f}", "subValue": f"倍率后 ${all_time_actual_cost:.2f}", "change": ( f"+${cost_today:.2f}" if cost_today > cost_yesterday else f"${cost_today:.2f}" ), "changeType": ( "increase" if cost_today > cost_yesterday else ("decrease" if cost_today < cost_yesterday else "neutral") ), "icon": "DollarSign", }, { "name": "总Token", "value": format_tokens( all_time_input_tokens + all_time_output_tokens + all_time_cache_creation + all_time_cache_read ), "subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}", "change": ( f"+{format_tokens(input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)}" if ( input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today ) > ( input_tokens_yesterday + output_tokens_yesterday + cache_creation_yesterday + cache_read_yesterday ) else format_tokens( input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today ) ), "changeType": ( "increase" if ( input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today ) > ( input_tokens_yesterday + output_tokens_yesterday + cache_creation_yesterday + cache_read_yesterday ) else ( "decrease" if ( input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today ) < ( input_tokens_yesterday + output_tokens_yesterday + cache_creation_yesterday + cache_read_yesterday ) else "neutral" ) ), "icon": "Hash", }, { "name": "总缓存", "value": format_tokens(all_time_cache_creation + all_time_cache_read), "subValue": f"创建 {format_tokens(all_time_cache_creation)} / 读取 {format_tokens(all_time_cache_read)}", "change": ( f"+{format_tokens(cache_creation_today + cache_read_today)}" if (cache_creation_today + cache_read_today) > (cache_creation_yesterday + cache_read_yesterday) else format_tokens(cache_creation_today + cache_read_today) ), "changeType": ( "increase" if (cache_creation_today + cache_read_today) > (cache_creation_yesterday + cache_read_yesterday) else ( "decrease" if (cache_creation_today + cache_read_today) < (cache_creation_yesterday + cache_read_yesterday) else "neutral" ) ), "extraBadge": f"命中率 {cache_hit_rate}%", "icon": "Database", }, ], "today": { "requests": requests_today, "cost": cost_today, "actual_cost": actual_cost_today, "tokens": tokens_today, "cache_creation_tokens": cache_creation_today, "cache_read_tokens": cache_read_today, }, "api_keys": {"total": total_api_keys, "active": active_api_keys}, "tokens": {"month": total_tokens}, "token_breakdown": { "input": all_time_input_tokens, "output": all_time_output_tokens, "cache_creation": all_time_cache_creation, "cache_read": all_time_cache_read, }, "system_health": { "avg_response_time": round(avg_response_time_seconds, 2), "error_rate": error_rate, "error_requests": error_requests, "fallback_count": fallback_count, "total_requests": total_requests, }, "cost_stats": { "total_cost": round(total_cost, 4), "total_actual_cost": round(total_actual_cost, 4), "cost_savings": round(total_cost - total_actual_cost, 4), }, "cache_stats": { "cache_creation_tokens": cache_creation_tokens, "cache_read_tokens": cache_read_tokens, "cache_creation_cost": round(cache_creation_cost, 4), "cache_read_cost": round(cache_read_cost, 4), "total_cache_tokens": cache_creation_tokens + cache_read_tokens, }, "users": { "total": total_users, "active": active_users, }, } class UserDashboardStatsAdapter(DashboardAdapter): @cache_result(key_prefix="dashboard:user:stats", ttl=30, user_specific=True) async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] from zoneinfo import ZoneInfo from src.config import config db = context.db user = context.user # 使用业务时区计算日期,确保与用户感知的"今天"一致 app_tz = ZoneInfo(config.app_timezone) now_local = datetime.now(app_tz) today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) # 转换为 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) api_key_stats = ( db.query( func.count(ApiKey.id).label("total"), func.sum(case((ApiKey.is_active.is_(True), 1), else_=0)).label("active"), ) .filter(ApiKey.user_id == user.id) .first() ) user_api_keys = int(api_key_stats.total or 0) if api_key_stats else 0 active_keys = int(api_key_stats.active or 0) if api_key_stats else 0 # 使用单次聚合查询返回全量 + 本月 + 今日 + 昨日统计 usage_stats = ( db.query( # 全量 Token 统计 func.sum(Usage.input_tokens).label("all_time_input_tokens"), func.sum(Usage.output_tokens).label("all_time_output_tokens"), func.sum(Usage.cache_creation_input_tokens).label("all_time_cache_creation_tokens"), func.sum(Usage.cache_read_input_tokens).label("all_time_cache_read_tokens"), # 本月 func.sum(case((Usage.created_at >= month_start, 1), else_=0)).label( "monthly_requests" ), func.sum( case((Usage.created_at >= month_start, Usage.total_cost_usd), else_=0.0) ).label("monthly_cost"), func.sum( case( (Usage.created_at >= month_start, Usage.cache_creation_input_tokens), else_=0, ) ).label("monthly_cache_creation_tokens"), func.sum( case((Usage.created_at >= month_start, Usage.cache_read_input_tokens), else_=0) ).label("monthly_cache_read_tokens"), func.sum( case((Usage.created_at >= month_start, Usage.input_tokens), else_=0) ).label("monthly_input_tokens"), # 今日 func.sum(case((Usage.created_at >= today, 1), else_=0)).label("today_requests"), func.sum(case((Usage.created_at >= today, Usage.total_cost_usd), else_=0.0)).label( "today_cost" ), func.sum(case((Usage.created_at >= today, Usage.total_tokens), else_=0)).label( "today_tokens" ), func.sum( case((Usage.created_at >= today, Usage.cache_creation_input_tokens), else_=0) ).label("today_cache_creation_tokens"), func.sum( case((Usage.created_at >= today, Usage.cache_read_input_tokens), else_=0) ).label("today_cache_read_tokens"), # 昨日(用于变化趋势) func.sum( case( ( and_( Usage.created_at >= yesterday, Usage.created_at < today, ), 1, ), else_=0, ) ).label("yesterday_requests"), ) .filter(Usage.user_id == user.id) .first() ) all_time_input_tokens = int(usage_stats.all_time_input_tokens or 0) if usage_stats else 0 all_time_output_tokens = int(usage_stats.all_time_output_tokens or 0) if usage_stats else 0 all_time_cache_creation = ( int(usage_stats.all_time_cache_creation_tokens or 0) if usage_stats else 0 ) all_time_cache_read = int(usage_stats.all_time_cache_read_tokens or 0) if usage_stats else 0 user_requests = int(usage_stats.monthly_requests or 0) if usage_stats else 0 user_cost = float(usage_stats.monthly_cost or 0.0) if usage_stats else 0.0 requests_today = int(usage_stats.today_requests or 0) if usage_stats else 0 cost_today = float(usage_stats.today_cost or 0.0) if usage_stats else 0.0 tokens_today = int(usage_stats.today_tokens or 0) if usage_stats else 0 requests_yesterday = int(usage_stats.yesterday_requests or 0) if usage_stats else 0 cache_creation_tokens = ( int(usage_stats.monthly_cache_creation_tokens or 0) if usage_stats else 0 ) cache_read_tokens = int(usage_stats.monthly_cache_read_tokens or 0) if usage_stats else 0 monthly_input_tokens = int(usage_stats.monthly_input_tokens or 0) if usage_stats else 0 # 计算本月缓存命中率:cache_read / (input_tokens + cache_read) # input_tokens 是实际发送给模型的输入(不含缓存读取),cache_read 是从缓存读取的 # 总输入 = input_tokens + cache_read,缓存命中率 = cache_read / 总输入 total_input_with_cache = monthly_input_tokens + cache_read_tokens cache_hit_rate = ( round((cache_read_tokens / total_input_with_cache) * 100, 1) if total_input_with_cache > 0 else 0 ) # 今日缓存统计 cache_creation_tokens_today = ( int(usage_stats.today_cache_creation_tokens or 0) if usage_stats else 0 ) cache_read_tokens_today = ( int(usage_stats.today_cache_read_tokens or 0) if usage_stats else 0 ) wallet = WalletService.get_wallet(db, user_id=user.id) billing = WalletService.serialize_wallet_summary(wallet) wallet_balance = float(billing["balance"]) wallet_consumed = float(billing["total_consumed"]) if bool(billing["unlimited"]): wallet_value = "无限制" wallet_change = f"累计消费 ${wallet_consumed:.2f}" wallet_high = False else: wallet_value = f"${wallet_balance:.2f}" wallet_change = f"累计消费 ${wallet_consumed:.2f}" wallet_high = wallet_balance <= 0 return { "stats": [ { "name": "API 密钥", "value": f"{active_keys}/{user_api_keys}", "icon": "Key", }, { "name": "本月请求", "value": f"{user_requests:,}", "change": f"今日 {requests_today}", "changeType": ( "increase" if requests_today > requests_yesterday else ("decrease" if requests_today < requests_yesterday else "neutral") ), "icon": "Activity", }, { "name": "钱包状态", "value": wallet_value, "change": wallet_change, "changeType": "increase" if wallet_high else "neutral", "icon": "TrendingUp", }, { "name": "总Token", "value": format_tokens( all_time_input_tokens + all_time_output_tokens + all_time_cache_creation + all_time_cache_read ), "subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}", "icon": "Hash", }, ], "today": { "requests": requests_today, "cost": cost_today, "tokens": tokens_today, "cache_creation_tokens": cache_creation_tokens_today, "cache_read_tokens": cache_read_tokens_today, }, # 全局 Token 详细分类(与管理员端对齐) "token_breakdown": { "input": all_time_input_tokens, "output": all_time_output_tokens, "cache_creation": all_time_cache_creation, "cache_read": all_time_cache_read, }, # 用户视角:缓存使用情况 "cache_stats": { "cache_creation_tokens": cache_creation_tokens, "cache_read_tokens": cache_read_tokens, "cache_hit_rate": cache_hit_rate, "total_cache_tokens": cache_creation_tokens + cache_read_tokens, }, # 本月费用(用于下方缓存区域显示) "monthly_cost": float(user_cost), } @dataclass class DashboardRecentRequestsAdapter(DashboardAdapter): limit: int @cache_result( key_prefix="dashboard:recent:requests", ttl=CacheTTL.ADMIN_USAGE_RECORDS, user_specific=True, vary_by=["limit"], ) async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] db = context.db user = context.user # Perf: select only required columns (avoid loading large JSON/BLOB fields). query = db.query( Usage.id, Usage.user_id, Usage.model, Usage.total_tokens, Usage.created_at, Usage.is_stream, DBUser.username, ).outerjoin(DBUser, DBUser.id == Usage.user_id) if user.role != UserRole.ADMIN: query = query.filter(Usage.user_id == user.id) rows = query.order_by(Usage.created_at.desc()).limit(self.limit).all() results = [] for req_id, _user_id, model, total_tokens, created_at, is_stream, username in rows: results.append( { "id": req_id, "user": username or "Unknown", "model": model or "N/A", "tokens": int(total_tokens or 0), "time": created_at.strftime("%H:%M") if created_at else None, "is_stream": bool(is_stream), } ) return {"requests": results} # NOTE: DashboardRequestDetailAdapter has been moved to AdminUsageDetailAdapter # in src/api/admin/usage/routes.py class DashboardProviderStatusAdapter(DashboardAdapter): @cache_result(key_prefix="dashboard:provider:status", ttl=60, user_specific=False) async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] db = context.db user = context.user providers = db.query(Provider).filter(Provider.is_active.is_(True)).all() since = datetime.now(timezone.utc) - timedelta(days=1) # Avoid N+1: compute 24h request counts for all providers in one GROUP BY query. provider_names = [p.name for p in providers if p and p.name] counts: dict[str, int] = {} if provider_names: rows = ( db.query(Usage.provider_name, func.count(Usage.id)) .filter(and_(Usage.created_at >= since, Usage.provider_name.in_(provider_names))) .group_by(Usage.provider_name) .all() ) counts = {str(name): int(cnt or 0) for name, cnt in rows if name} entries = [] for provider in providers: entries.append( { "name": provider.name, "status": "active" if provider.is_active else "inactive", "requests": int(counts.get(provider.name, 0)), } ) entries.sort(key=lambda x: x["requests"], reverse=True) limit = 10 if user.role == UserRole.ADMIN else 5 return {"providers": entries[:limit]} @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, vary_by=[ "start_date", "end_date", "preset", "granularity", "timezone", "tz_offset_minutes", ], ) async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] 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, } ) # 补充 unique_models / unique_providers # query_time_series 使用小时粒度数据,不含这些维度统计 # 使用 CASE 一次性分桶,避免按天循环查询造成 N 次 SQL。 granularity = (self.time_range.granularity or "day").lower() local_days = self.time_range.get_local_day_hours() range_start = local_days[0][1] if local_days else None range_end = local_days[-1][2] if local_days else None day_bucket = ( case( *[ ( and_(Usage.created_at >= day_start, Usage.created_at < day_end), local_date.isoformat(), ) for local_date, day_start, day_end in local_days ], else_=None, ).label("local_day") if local_days else None ) if ( formatted and granularity == "day" and day_bucket is not None and range_start and range_end ): enrichment: dict[str, dict[str, int]] = {} enrich_query = db.query( day_bucket, func.count(func.distinct(Usage.model)).label("um"), func.count(func.distinct(Usage.provider_name)).label("up"), ).filter( Usage.created_at >= range_start, Usage.created_at < range_end, ) if not is_admin: enrich_query = enrich_query.filter(Usage.user_id == user.id) enrich_rows = enrich_query.group_by(day_bucket).all() for local_day, unique_models, unique_providers in enrich_rows: if not local_day: continue enrichment[str(local_day)] = { "unique_models": int(unique_models or 0), "unique_providers": int(unique_providers or 0), } for item in formatted: date_key = item["date"][:10] # YYYY-MM-DD if date_key in enrichment: item["unique_models"] = enrichment[date_key]["unique_models"] item["unique_providers"] = enrichment[date_key]["unique_providers"] # 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]] = {} if granularity == "day" and day_bucket is not None and range_start and range_end: breakdown_query = db.query( day_bucket, 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 >= range_start, Usage.created_at < range_end, ) if not is_admin: breakdown_query = breakdown_query.filter(Usage.user_id == user.id) breakdown_rows = ( breakdown_query.group_by(day_bucket, Usage.model) .order_by(day_bucket.asc(), func.sum(Usage.total_cost_usd).desc()) .all() ) for local_day, model_name, requests, tokens, cost in breakdown_rows: if not local_day or not model_name: continue breakdown_map.setdefault(str(local_day), []).append( { "model": model_name, "requests": int(requests or 0), "tokens": int(tokens or 0), "cost": float(cost or 0.0), } ) 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.config import config app_tz = ZoneInfo(config.app_timezone) now_local = datetime.now(app_tz) today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) # 转换为 UTC 用于数据库查询 today = today_local.astimezone(timezone.utc) end_date_local = now_local.replace(hour=23, minute=59, second=59, microsecond=999999) end_date = end_date_local.astimezone(timezone.utc) start_date_local = today_local - timedelta(days=self.days - 1) start_date = start_date_local.astimezone(timezone.utc) # ==================== 使用预聚合数据优化 ==================== if is_admin: # 管理员:从 stats_daily 获取历史数据 daily_stats = ( db.query(StatsDaily) .filter(and_(StatsDaily.date >= start_date, StatsDaily.date < today)) .order_by(StatsDaily.date.asc()) .all() ) # stats_daily.date 存储的是业务日期对应的 UTC 开始时间 # 需要转回业务时区再取日期,才能与日期序列匹配 def _to_business_date_str(value: datetime) -> str: if value.tzinfo is None: value_utc = value.replace(tzinfo=timezone.utc) else: value_utc = value.astimezone(timezone.utc) return value_utc.astimezone(app_tz).date().isoformat() stats_map = { _to_business_date_str(stat.date): { "requests": stat.total_requests, "tokens": stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens, "cost": float(stat.total_cost or 0), "avg_response_time": ( stat.avg_response_time_ms / 1000.0 if stat.avg_response_time_ms else 0 ), "unique_models": getattr(stat, "unique_models", 0) or 0, "unique_providers": getattr(stat, "unique_providers", 0) or 0, "fallback_count": stat.fallback_count or 0, } for stat in daily_stats } # 今日实时数据 today_stats = StatsAggregatorService.get_today_realtime_stats(db) today_str = today_local.date().isoformat() if today_stats["total_requests"] > 0: today_avg_rt_ms = float(today_stats.get("avg_response_time_ms") or 0.0) today_unique_models = int(today_stats.get("unique_models") or 0) today_unique_providers = int(today_stats.get("unique_providers") or 0) # 今日 fallback_count today_fallback_count = ( db.query(func.count()) .select_from( db.query(RequestCandidate.request_id) .filter( RequestCandidate.created_at >= today, RequestCandidate.status.in_(["success", "failed"]), ) .group_by(RequestCandidate.request_id) .having(func.count(RequestCandidate.id) > 1) .subquery() ) .scalar() or 0 ) stats_map[today_str] = { "requests": today_stats["total_requests"], "tokens": ( today_stats["input_tokens"] + today_stats["output_tokens"] + today_stats["cache_creation_tokens"] + today_stats["cache_read_tokens"] ), "cost": float(today_stats["total_cost"]), "avg_response_time": today_avg_rt_ms / 1000.0 if today_avg_rt_ms else 0, "unique_models": today_unique_models, "unique_providers": today_unique_providers, "fallback_count": today_fallback_count, } # 历史预聚合缺失时兜底:按业务日范围实时计算(仅补最近少量缺失,避免全表扫描) yesterday_date = today_local.date() - timedelta(days=1) historical_end = min(end_date_local.date(), yesterday_date) missing_dates: list[str] = [] cursor = start_date_local.date() while cursor <= historical_end: date_str = cursor.isoformat() if date_str not in stats_map: missing_dates.append(date_str) cursor += timedelta(days=1) if missing_dates: for date_str in missing_dates[-7:]: target_local = datetime.fromisoformat(date_str).replace(tzinfo=app_tz) computed = StatsAggregatorService.compute_daily_stats(db, target_local) stats_map[date_str] = { "requests": computed["total_requests"], "tokens": ( computed["input_tokens"] + computed["output_tokens"] + computed["cache_creation_tokens"] + computed["cache_read_tokens"] ), "cost": computed["total_cost"], "avg_response_time": ( computed["avg_response_time_ms"] / 1000.0 if computed["avg_response_time_ms"] else 0 ), "unique_models": computed["unique_models"], "unique_providers": computed["unique_providers"], "fallback_count": computed["fallback_count"], } else: # 普通用户:仍需实时查询(用户级预聚合可选) query = db.query(Usage).filter( and_( Usage.user_id == user.id, Usage.created_at >= start_date, Usage.created_at <= end_date, ) ) user_daily_stats = ( query.with_entities( func.date(Usage.created_at).label("date"), func.count(Usage.id).label("requests"), func.sum(Usage.total_tokens).label("tokens"), func.sum(Usage.total_cost_usd).label("cost"), func.avg(Usage.response_time_ms).label("avg_response_time"), ) .group_by(func.date(Usage.created_at)) .order_by(func.date(Usage.created_at).asc()) .all() ) stats_map = { stat.date.isoformat(): { "requests": stat.requests or 0, "tokens": int(stat.tokens or 0), "cost": float(stat.cost or 0), "avg_response_time": ( float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0 ), } for stat in user_daily_stats } # 构建完整日期序列(使用业务时区日期) current_date = start_date_local.date() end_date_date = end_date_local.date() formatted: list[dict] = [] while current_date <= end_date_date: date_str = current_date.isoformat() stat = stats_map.get(date_str) if stat: item = { "date": date_str, "requests": stat["requests"], "tokens": stat["tokens"], "cost": stat["cost"], "avg_response_time": stat["avg_response_time"], "unique_models": stat.get("unique_models", 0), "fallback_count": stat.get("fallback_count", 0), } # 仅管理员返回 unique_providers if is_admin: item["unique_providers"] = stat.get("unique_providers", 0) formatted.append(item) else: item = { "date": date_str, "requests": 0, "tokens": 0, "cost": 0.0, "avg_response_time": 0.0, "unique_models": 0, "fallback_count": 0, } # 仅管理员返回 unique_providers if is_admin: item["unique_providers"] = 0 formatted.append(item) current_date += timedelta(days=1) # ==================== 模型统计 ==================== if is_admin: # 管理员:使用预聚合数据 + 今日实时数据 # 历史数据从 stats_daily_model 获取 historical_model_stats = ( db.query(StatsDailyModel) .filter(and_(StatsDailyModel.date >= start_date, StatsDailyModel.date < today)) .all() ) # 按模型汇总历史数据 model_agg: dict = {} daily_breakdown: dict = {} for stat in historical_model_stats: model = stat.model if model not in model_agg: model_agg[model] = { "requests": 0, "tokens": 0, "cost": 0.0, "total_response_time": 0.0, "response_count": 0, } model_agg[model]["requests"] += stat.total_requests tokens = ( stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens ) model_agg[model]["tokens"] += tokens model_agg[model]["cost"] += float(stat.total_cost or 0) if stat.avg_response_time_ms is not None: model_agg[model]["total_response_time"] += ( float(stat.avg_response_time_ms) * stat.total_requests ) model_agg[model]["response_count"] += stat.total_requests # 按日期分组 if stat.date.tzinfo is None: date_utc = stat.date.replace(tzinfo=timezone.utc) else: date_utc = stat.date.astimezone(timezone.utc) date_str = date_utc.astimezone(app_tz).date().isoformat() daily_breakdown.setdefault(date_str, []).append( { "model": model, "requests": stat.total_requests, "tokens": tokens, "cost": float(stat.total_cost or 0), } ) # 今日实时模型统计 today_model_stats = ( 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"), func.avg(Usage.response_time_ms).label("avg_response_time"), ) .filter(Usage.created_at >= today) .group_by(Usage.model) .all() ) today_str = today_local.date().isoformat() for stat in today_model_stats: model = stat.model if model not in model_agg: model_agg[model] = { "requests": 0, "tokens": 0, "cost": 0.0, "total_response_time": 0.0, "response_count": 0, } model_agg[model]["requests"] += stat.requests or 0 model_agg[model]["tokens"] += int(stat.tokens or 0) model_agg[model]["cost"] += float(stat.cost or 0) if stat.avg_response_time is not None: model_agg[model]["total_response_time"] += float(stat.avg_response_time) * ( stat.requests or 0 ) model_agg[model]["response_count"] += stat.requests or 0 # 今日 breakdown daily_breakdown.setdefault(today_str, []).append( { "model": model, "requests": stat.requests or 0, "tokens": int(stat.tokens or 0), "cost": float(stat.cost or 0), } ) # 构建 model_summary model_summary = [] for model, agg in model_agg.items(): avg_rt = ( agg["total_response_time"] / agg["response_count"] / 1000.0 if agg["response_count"] > 0 else 0 ) model_summary.append( { "model": model, "requests": agg["requests"], "tokens": agg["tokens"], "cost": agg["cost"], "avg_response_time": avg_rt, "cost_per_request": agg["cost"] / max(agg["requests"], 1), "tokens_per_request": agg["tokens"] / max(agg["requests"], 1), } ) model_summary.sort(key=lambda x: x["cost"], reverse=True) # 填充 model_breakdown for item in formatted: item["model_breakdown"] = daily_breakdown.get(item["date"], []) else: # 普通用户:实时查询(数据量较小) model_query = db.query(Usage).filter( and_( Usage.user_id == user.id, Usage.created_at >= start_date, Usage.created_at <= end_date, ) ) model_stats = ( model_query.with_entities( Usage.model, func.count(Usage.id).label("requests"), func.sum(Usage.total_tokens).label("tokens"), func.sum(Usage.total_cost_usd).label("cost"), func.avg(Usage.response_time_ms).label("avg_response_time"), ) .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": ( float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 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_stats = ( model_query.with_entities( func.date(Usage.created_at).label("date"), Usage.model, func.count(Usage.id).label("requests"), func.sum(Usage.total_tokens).label("tokens"), func.sum(Usage.total_cost_usd).label("cost"), ) .group_by(func.date(Usage.created_at), Usage.model) .order_by(func.date(Usage.created_at).desc(), func.sum(Usage.total_cost_usd).desc()) .all() ) breakdown = {} for stat in daily_model_stats: date_str = stat.date.isoformat() breakdown.setdefault(date_str, []).append( { "model": stat.model, "requests": stat.requests or 0, "tokens": int(stat.tokens or 0), "cost": float(stat.cost or 0), } ) for item in formatted: item["model_breakdown"] = breakdown.get(item["date"], []) # 普通用户不返回 provider_summary provider_summary = None # ==================== 供应商统计(仅管理员)==================== if is_admin: # 管理员:使用预聚合数据 + 今日实时数据 # 历史数据从 stats_daily_provider 获取 historical_provider_stats = ( db.query(StatsDailyProvider) .filter( and_(StatsDailyProvider.date >= start_date, StatsDailyProvider.date < today) ) .all() ) # 按供应商汇总历史数据 provider_agg: dict[str, dict[str, int | float]] = {} for stat in historical_provider_stats: provider = stat.provider_name or "Unknown" if provider not in provider_agg: provider_agg[provider] = {"requests": 0, "tokens": 0, "cost": 0.0} provider_agg[provider]["requests"] += stat.total_requests tokens = ( stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens ) provider_agg[provider]["tokens"] += tokens provider_agg[provider]["cost"] += float(stat.total_cost or 0) # 今日实时供应商统计 today_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 >= today) .group_by(Usage.provider_name) .all() ) for stat in today_provider_stats: provider = stat.provider_name or "Unknown" if provider not in provider_agg: provider_agg[provider] = {"requests": 0, "tokens": 0, "cost": 0.0} provider_agg[provider]["requests"] += stat.requests or 0 provider_agg[provider]["tokens"] += int(stat.tokens or 0) provider_agg[provider]["cost"] += float(stat.cost or 0) # 构建 provider_summary(排除 unknown) provider_summary = [ { "provider": provider, "requests": agg["requests"], "tokens": agg["tokens"], "cost": agg["cost"], } for provider, agg in provider_agg.items() if provider.lower() != "unknown" ] provider_summary.sort(key=lambda x: x["cost"], reverse=True) # 构建返回结果 result = { "daily_stats": formatted, "model_summary": model_summary, "period": { "start_date": start_date.date().isoformat(), "end_date": end_date.date().isoformat(), "days": self.days, }, } # 仅管理员返回 provider_summary if is_admin and provider_summary: result["provider_summary"] = provider_summary return result