Files
Aether/_deprecated_py_src/api/admin/stats/cost.py
fawney19 1d9c77522a refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
2026-04-03 16:26:16 +08:00

216 lines
7.5 KiB
Python

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