316 lines
16 KiB
Python
316 lines
16 KiB
Python
"""用量计量、费率快照和用户配额。
|
|||
|
|
|
||
|
|
UsageEvent 是追加式账本:同一个幂等键只允许落一条记录,历史金额不会随
|
||
|
|
Provider 当前配置变化。额度只读账本聚合,不把前端传入的数字当作已用量。
|
||
|
|
"""
|
||
|
|
from datetime import datetime
|
||
|
|
from math import ceil
|
||
|
|
from typing import Iterable
|
||
|
|
|
||
|
|
from fastapi import HTTPException
|
||
|
|
from sqlmodel import Session, select
|
||
|
|
from sqlalchemy import update
|
||
|
|
|
||
|
|
from .models import (ModelCatalog, ModelProvider, PointAccount, PointTransaction,
|
||
|
|
UsageEvent, UsageQuota, now)
|
||
|
|
|
||
|
|
POINTS_PER_TOKEN = 10
|
||
|
|
MIN_AI_POINTS = POINTS_PER_TOKEN
|
||
|
|
|
||
|
|
|
||
|
|
def _points_write_lock(session: Session) -> None:
|
||
|
|
"""Serialize the short ledger transaction, never the preceding model request.
|
||
|
|
|
||
|
|
SQLite's legacy driver does not begin a transaction for SELECT. Acquire the
|
||
|
|
write reservation before idempotency/account reads, and leave commit/rollback
|
||
|
|
to the caller so the balance and ledger remain one transaction.
|
||
|
|
"""
|
||
|
|
connection = session.connection()
|
||
|
|
if connection.dialect.name == 'sqlite' and not connection.connection.driver_connection.in_transaction:
|
||
|
|
connection.exec_driver_sql('BEGIN IMMEDIATE')
|
||
|
|
|
||
|
|
|
||
|
|
def _refresh_balance(session, account):
|
||
|
|
session.expire(account, ['balance_points', 'updated_at'])
|
||
|
|
|
||
|
|
|
||
|
|
def period_key(period: str = "monthly", value: 'str | None' = None) -> str:
|
||
|
|
"""返回用于账单筛选的日/月键。"""
|
||
|
|
current = value or now()
|
||
|
|
return current[:10] if period == "daily" else current[:7]
|
||
|
|
|
||
|
|
|
||
|
|
def estimate_tokens(value: object) -> int:
|
||
|
|
"""无 tokenizer 时的保守估算;事件会标记为 estimated。"""
|
||
|
|
text = str(value or "")
|
||
|
|
return max(1, ceil(len(text) / 2)) if text else 0
|
||
|
|
|
||
|
|
|
||
|
|
def point_state(session: Session, user_id: int, organization_id: int = 1) -> dict:
|
||
|
|
"""返回用户点数余额及可折算的 Token 数;没有账户时按 0 点数处理。"""
|
||
|
|
account = session.exec(select(PointAccount).where(
|
||
|
|
PointAccount.user_id == int(user_id or 0),
|
||
|
|
PointAccount.organization_id == int(organization_id or 1),
|
||
|
|
)).first()
|
||
|
|
points = max(0, int(account.balance_points or 0)) if account else 0
|
||
|
|
return {"points": points, "token_equivalent": points // POINTS_PER_TOKEN,
|
||
|
|
"remainder_points": points % POINTS_PER_TOKEN,
|
||
|
|
"points_per_token": POINTS_PER_TOKEN}
|
||
|
|
|
||
|
|
|
||
|
|
def _point_account(session: Session, user_id: int, organization_id: int) -> PointAccount:
|
||
|
|
account = session.exec(select(PointAccount).where(
|
||
|
|
PointAccount.user_id == int(user_id),
|
||
|
|
PointAccount.organization_id == int(organization_id),
|
||
|
|
)).first()
|
||
|
|
if account:
|
||
|
|
return account
|
||
|
|
account = PointAccount(user_id=int(user_id), organization_id=int(organization_id), balance_points=0)
|
||
|
|
session.add(account)
|
||
|
|
session.flush()
|
||
|
|
return account
|
||
|
|
|
||
|
|
|
||
|
|
def grant_points(session: Session, *, user_id: int, organization_id: int,
|
||
|
|
points: int, reason: str, operator_user_id: int = 0,
|
||
|
|
idempotency_key: str = "", kind: str = "credit",
|
||
|
|
source: str = "admin", metadata: dict | None = None) -> PointTransaction:
|
||
|
|
"""给用户增加点数,幂等键确保重复提交不会重复充值。"""
|
||
|
|
amount = int(points or 0)
|
||
|
|
if amount <= 0:
|
||
|
|
raise ValueError("增加点数必须大于 0")
|
||
|
|
key = str(idempotency_key or "").strip()
|
||
|
|
if not key:
|
||
|
|
raise ValueError("点数流水缺少幂等键")
|
||
|
|
_points_write_lock(session)
|
||
|
|
existing = session.exec(select(PointTransaction).where(
|
||
|
|
PointTransaction.idempotency_key == key)).first()
|
||
|
|
if existing:
|
||
|
|
return existing
|
||
|
|
account = _point_account(session, user_id, organization_id)
|
||
|
|
session.execute(update(PointAccount).where(PointAccount.id == account.id).values(
|
||
|
|
balance_points=PointAccount.balance_points + amount, updated_at=now()),
|
||
|
|
execution_options={'synchronize_session': False})
|
||
|
|
_refresh_balance(session, account)
|
||
|
|
transaction = PointTransaction(
|
||
|
|
user_id=int(user_id), organization_id=int(organization_id),
|
||
|
|
delta_points=amount, kind=str(kind or "credit"), reason=str(reason or ""),
|
||
|
|
source=str(source or "admin"), operator_user_id=int(operator_user_id or 0),
|
||
|
|
idempotency_key=key, transaction_metadata=metadata or {},
|
||
|
|
)
|
||
|
|
session.add(transaction)
|
||
|
|
session.flush()
|
||
|
|
return transaction
|
||
|
|
|
||
|
|
|
||
|
|
def consume_points(session: Session, *, user_id: int, organization_id: int,
|
||
|
|
points: int, reason: str, idempotency_key: str,
|
||
|
|
metadata: dict | None = None) -> bool:
|
||
|
|
"""扣减点数;余额不足时保持 0 并返回 False。"""
|
||
|
|
amount = max(0, int(points or 0))
|
||
|
|
if amount == 0:
|
||
|
|
return True
|
||
|
|
key = str(idempotency_key or "").strip()
|
||
|
|
if not key:
|
||
|
|
raise ValueError("点数扣减缺少幂等键")
|
||
|
|
_points_write_lock(session)
|
||
|
|
existing = session.exec(select(PointTransaction).where(
|
||
|
|
PointTransaction.idempotency_key == key)).first()
|
||
|
|
if existing:
|
||
|
|
return existing.delta_points < 0
|
||
|
|
account = session.exec(select(PointAccount).where(
|
||
|
|
PointAccount.user_id == int(user_id),
|
||
|
|
PointAccount.organization_id == int(organization_id),
|
||
|
|
)).first()
|
||
|
|
if not account:
|
||
|
|
return False
|
||
|
|
charged = session.execute(update(PointAccount).where(
|
||
|
|
PointAccount.id == account.id, PointAccount.balance_points >= amount).values(
|
||
|
|
balance_points=PointAccount.balance_points - amount, updated_at=now()),
|
||
|
|
execution_options={'synchronize_session': False})
|
||
|
|
_refresh_balance(session, account)
|
||
|
|
if charged.rowcount != 1:
|
||
|
|
return False
|
||
|
|
session.add(PointTransaction(
|
||
|
|
user_id=int(user_id), organization_id=int(organization_id),
|
||
|
|
delta_points=-amount, kind="debit", reason=str(reason or "模型调用"),
|
||
|
|
source="llm", idempotency_key=key, transaction_metadata=metadata or {},
|
||
|
|
))
|
||
|
|
session.flush()
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def require_ai_points(session: Session, *, user_id: int, organization_id: int = 1) -> dict:
|
||
|
|
"""模型请求前的最低点数门槛:少于 10 点数直接停止调用。"""
|
||
|
|
state = point_state(session, user_id, organization_id)
|
||
|
|
if int(user_id or 0) and state["points"] < MIN_AI_POINTS:
|
||
|
|
raise ValueError(f"点数余额不足,至少需要 {MIN_AI_POINTS} 点数(1 Token)才能使用 AI")
|
||
|
|
return state
|
||
|
|
|
||
|
|
|
||
|
|
def pricing_for(session: Session, provider_id: int = 0, model: str = "") -> dict:
|
||
|
|
catalog = session.exec(select(ModelCatalog).where(
|
||
|
|
ModelCatalog.provider_id == int(provider_id or 0),
|
||
|
|
ModelCatalog.model_name == str(model or ""),
|
||
|
|
ModelCatalog.enabled == True, # noqa: E712
|
||
|
|
)).first()
|
||
|
|
if catalog:
|
||
|
|
return {"input_rate": float(catalog.input_rate or 0),
|
||
|
|
"output_rate": float(catalog.output_rate or 0),
|
||
|
|
"version": catalog.rate_version or "v1"}
|
||
|
|
provider = session.get(ModelProvider, int(provider_id or 0)) if provider_id else None
|
||
|
|
if provider and model in (provider.models or []):
|
||
|
|
return {"input_rate": 0.0, "output_rate": 0.0, "version": "v1"}
|
||
|
|
return {"input_rate": 0.0, "output_rate": 0.0, "version": "v1"}
|
||
|
|
|
||
|
|
|
||
|
|
def amount_minor(input_quantity: int, output_quantity: int,
|
||
|
|
input_rate: float, output_rate: float) -> int:
|
||
|
|
amount = (max(0, int(input_quantity or 0)) / 1_000_000 * max(0.0, float(input_rate or 0)))
|
||
|
|
amount += (max(0, int(output_quantity or 0)) / 1_000_000 * max(0.0, float(output_rate or 0)))
|
||
|
|
return int(round(amount * 100))
|
||
|
|
|
||
|
|
|
||
|
|
def record_usage_event(session: Session, *, idempotency_key: str,
|
||
|
|
user_id: int = 0, organization_id: int = 1,
|
||
|
|
campaign_id: int = 0, run_id: int = 0, segment_id: int = 0,
|
||
|
|
trace_id: str = "", llm_call_id: int | None = None,
|
||
|
|
agent_id: int = 0, provider_id: int = 0,
|
||
|
|
provider: str = "", model: str = "",
|
||
|
|
service_type: str = "llm", unit: str = "token",
|
||
|
|
quantity: float = 0.0, input_quantity: int = 0,
|
||
|
|
output_quantity: int = 0, input_rate: float = 0.0,
|
||
|
|
output_rate: float = 0.0, pricing_version: str = "v1",
|
||
|
|
status: str = "measured", metadata: dict | None = None,
|
||
|
|
amount_minor_override: int | None = None) -> UsageEvent:
|
||
|
|
"""追加一条事件;重复幂等键直接复用原事件,不更新历史账本。"""
|
||
|
|
key = str(idempotency_key or "").strip()
|
||
|
|
if not key:
|
||
|
|
raise ValueError("UsageEvent 缺少幂等键")
|
||
|
|
existing = session.exec(select(UsageEvent).where(UsageEvent.idempotency_key == key)).first()
|
||
|
|
if existing:
|
||
|
|
return existing
|
||
|
|
event = UsageEvent(
|
||
|
|
idempotency_key=key,
|
||
|
|
user_id=int(user_id or 0), organization_id=int(organization_id or 1),
|
||
|
|
campaign_id=int(campaign_id or 0), run_id=int(run_id or 0),
|
||
|
|
segment_id=int(segment_id or 0), trace_id=str(trace_id or ""),
|
||
|
|
llm_call_id=llm_call_id, agent_id=int(agent_id or 0),
|
||
|
|
provider_id=int(provider_id or 0), provider=str(provider or ""),
|
||
|
|
model=str(model or ""), service_type=str(service_type or "llm"),
|
||
|
|
unit=str(unit or "token"), quantity=float(quantity or 0),
|
||
|
|
input_quantity=max(0, int(input_quantity or 0)),
|
||
|
|
output_quantity=max(0, int(output_quantity or 0)),
|
||
|
|
input_rate=float(input_rate or 0), output_rate=float(output_rate or 0),
|
||
|
|
amount_minor=(int(amount_minor_override) if amount_minor_override is not None else
|
||
|
|
amount_minor(input_quantity, output_quantity, input_rate, output_rate)),
|
||
|
|
pricing_version=str(pricing_version or "v1"), status=str(status or "measured"),
|
||
|
|
usage_metadata=metadata or {},
|
||
|
|
)
|
||
|
|
session.add(event)
|
||
|
|
session.flush()
|
||
|
|
return event
|
||
|
|
|
||
|
|
|
||
|
|
def events_for_period(session: Session, *, user_id: int | None = None,
|
||
|
|
organization_id: int | None = None, period: str = "monthly",
|
||
|
|
service_type: str = "", status: str = "") -> list[UsageEvent]:
|
||
|
|
query = select(UsageEvent).order_by(UsageEvent.id.desc())
|
||
|
|
if user_id is not None:
|
||
|
|
query = query.where(UsageEvent.user_id == int(user_id))
|
||
|
|
if organization_id is not None:
|
||
|
|
query = query.where(UsageEvent.organization_id == int(organization_id))
|
||
|
|
if service_type:
|
||
|
|
query = query.where(UsageEvent.service_type == service_type)
|
||
|
|
if status:
|
||
|
|
query = query.where(UsageEvent.status == status)
|
||
|
|
key = period_key(period)
|
||
|
|
return [event for event in session.exec(query).all()
|
||
|
|
if (event.created_at or "").startswith(key)]
|
||
|
|
|
||
|
|
|
||
|
|
def usage_summary(events: Iterable[UsageEvent]) -> dict:
|
||
|
|
rows = list(events)
|
||
|
|
valid = [event for event in rows if event.status != "void"]
|
||
|
|
input_tokens = sum(int(event.input_quantity or 0) for event in valid)
|
||
|
|
output_tokens = sum(int(event.output_quantity or 0) for event in valid)
|
||
|
|
amount = sum(int(event.amount_minor or 0) for event in valid)
|
||
|
|
|
||
|
|
def group(field: str) -> dict:
|
||
|
|
grouped: dict[str, dict] = {}
|
||
|
|
for event in valid:
|
||
|
|
key = str(getattr(event, field) or "unknown")
|
||
|
|
item = grouped.setdefault(key, {"events": 0, "quantity": 0.0,
|
||
|
|
"input_tokens": 0, "output_tokens": 0,
|
||
|
|
"amount_minor": 0})
|
||
|
|
item["events"] += 1
|
||
|
|
item["quantity"] += float(event.quantity or 0)
|
||
|
|
item["input_tokens"] += int(event.input_quantity or 0)
|
||
|
|
item["output_tokens"] += int(event.output_quantity or 0)
|
||
|
|
item["amount_minor"] += int(event.amount_minor or 0)
|
||
|
|
return grouped
|
||
|
|
|
||
|
|
return {"events": len(valid), "input_tokens": input_tokens,
|
||
|
|
"output_tokens": output_tokens, "tokens": input_tokens + output_tokens,
|
||
|
|
"amount_minor": amount, "amount": amount / 100,
|
||
|
|
"by_model": group("model"), "by_service": group("service_type"),
|
||
|
|
"estimated_events": sum(event.status == "estimated" for event in valid),
|
||
|
|
"measured_events": sum(event.status == "measured" for event in valid),
|
||
|
|
"settled_events": sum(event.status == "settled" for event in valid)}
|
||
|
|
|
||
|
|
|
||
|
|
def event_payload(event: UsageEvent, user=None) -> dict:
|
||
|
|
return {"id": event.id, "user_id": event.user_id, "display_name": (getattr(user, "display_name", "") or getattr(user, "username", "") or ("系统" if not event.user_id else "")), "username": getattr(user, "username", "") or ("系统" if not event.user_id else ""), "organization_id": event.organization_id,
|
||
|
|
"campaign_id": event.campaign_id, "run_id": event.run_id, "segment_id": event.segment_id,
|
||
|
|
"trace_id": event.trace_id, "llm_call_id": event.llm_call_id, "agent_id": event.agent_id,
|
||
|
|
"provider_id": event.provider_id, "provider": event.provider, "model": event.model,
|
||
|
|
"service_type": event.service_type, "unit": event.unit, "quantity": event.quantity,
|
||
|
|
"input_quantity": event.input_quantity, "output_quantity": event.output_quantity,
|
||
|
|
"input_rate": event.input_rate, "output_rate": event.output_rate,
|
||
|
|
"amount_minor": event.amount_minor, "amount": event.amount_minor / 100,
|
||
|
|
"currency": event.currency, "pricing_version": event.pricing_version,
|
||
|
|
"status": event.status, "idempotency_key": event.idempotency_key,
|
||
|
|
"metadata": event.usage_metadata, "created_at": event.created_at}
|
||
|
|
|
||
|
|
|
||
|
|
def quota_for(session: Session, user_id: int, organization_id: int = 1,
|
||
|
|
period: str = "monthly") -> UsageQuota | None:
|
||
|
|
return session.exec(select(UsageQuota).where(
|
||
|
|
UsageQuota.user_id == int(user_id), UsageQuota.organization_id == int(organization_id),
|
||
|
|
UsageQuota.period == period, UsageQuota.enabled == True, # noqa: E712
|
||
|
|
)).first()
|
||
|
|
|
||
|
|
|
||
|
|
def quota_state(session: Session, user_id: int, organization_id: int = 1,
|
||
|
|
period: str = "monthly") -> dict:
|
||
|
|
quota = quota_for(session, user_id, organization_id, period)
|
||
|
|
events = events_for_period(session, user_id=user_id, organization_id=organization_id, period=period)
|
||
|
|
summary = usage_summary(events)
|
||
|
|
if not quota:
|
||
|
|
return {"configured": False, "allowed": True, "warning": False,
|
||
|
|
"quota": None, "used": summary}
|
||
|
|
token_limit = max(0, int(quota.token_limit or 0))
|
||
|
|
amount_limit = max(0, int(quota.amount_limit_minor or 0))
|
||
|
|
token_ratio = summary["tokens"] / token_limit if token_limit else 0
|
||
|
|
amount_ratio = summary["amount_minor"] / amount_limit if amount_limit else 0
|
||
|
|
ratio = max(token_ratio, amount_ratio)
|
||
|
|
exceeded = (token_limit and summary["tokens"] >= token_limit) or (amount_limit and summary["amount_minor"] >= amount_limit)
|
||
|
|
blocked = bool(exceeded and quota.overage_policy in {"soft_limit", "hard_limit"})
|
||
|
|
return {"configured": True, "allowed": not blocked, "warning": ratio >= float(quota.warning_ratio or .8),
|
||
|
|
"quota": {"id": quota.id, "period": quota.period, "token_limit": token_limit,
|
||
|
|
"amount_limit_minor": amount_limit, "storage_limit_bytes": quota.storage_limit_bytes,
|
||
|
|
"concurrent_limit": quota.concurrent_limit, "warning_ratio": quota.warning_ratio,
|
||
|
|
"overage_policy": quota.overage_policy, "enabled": quota.enabled},
|
||
|
|
"used": summary, "ratio": ratio, "exceeded": bool(exceeded)}
|
||
|
|
|
||
|
|
|
||
|
|
def check_quota(session: Session, user_id: int, organization_id: int = 1,
|
||
|
|
period: str = "monthly", estimated_tokens: int = 0) -> dict:
|
||
|
|
state = quota_state(session, user_id, organization_id, period)
|
||
|
|
quota = state.get("quota") or {}
|
||
|
|
limit = int(quota.get("token_limit") or 0)
|
||
|
|
projected = int(state["used"]["tokens"] or 0) + max(0, int(estimated_tokens or 0))
|
||
|
|
if limit and projected >= limit and quota.get("overage_policy") in {"soft_limit", "hard_limit"}:
|
||
|
|
raise HTTPException(429, "本周期用量已达到管理员设置的 Token 配额")
|
||
|
|
return state
|