guard.py 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
  1. """计费预检。
  2. 策略是 **fail-closed**(沿用 1.5 的取舍):预检自身出问题时不放行,
  3. 因为放行的代价是「无法计费的请求被白跑」,而拦截的代价只是用户重试一次。
  4. 两类失败要区分开,因为它们对应不同的 HTTP 语义:
  5. - :class:`QuotaExceededError` → 429(用户问题,重试无用,等额度重置)
  6. - :class:`BillingConfigError` → 500(服务端配置问题,运维要处理)
  7. """
  8. from __future__ import annotations
  9. from typing import Any
  10. import config
  11. from db import billing_store, db_conn
  12. from obs import get_logger
  13. logger = get_logger(__name__)
  14. class QuotaExceededError(RuntimeError):
  15. """当日 token 额度已用尽。"""
  16. def __init__(self, user_name: str, usage_day: str, quota: dict[str, Any]) -> None:
  17. self.user_name = user_name
  18. self.usage_day = usage_day
  19. self.quota = quota
  20. super().__init__(
  21. f"用户 {user_name} 在 {usage_day} 的 token 额度已用尽"
  22. f"({quota.get('used_tokens', 0)}/{quota.get('limit_tokens', 0)})"
  23. )
  24. class BillingConfigError(RuntimeError):
  25. """计费依赖不可用(数据库异常、单价缺失等),需要运维介入。"""
  26. def precheck(user_name: str, usage_day: str | None = None) -> dict[str, Any]:
  27. """对话开始前检查额度。通过则返回当前额度快照。"""
  28. day = usage_day or db_conn.day_key()
  29. try:
  30. quota = billing_store.get_quota(user_name, day)
  31. except Exception as exc:
  32. raise BillingConfigError(f"额度预检失败(计费库不可用):{type(exc).__name__}: {exc}") from exc
  33. limit_tokens = int(quota.get("limit_tokens") or 0)
  34. used_tokens = int(quota.get("used_tokens") or 0)
  35. # limit_tokens == 0 表示不限量(默认),此时只做读取不拦截
  36. if limit_tokens > 0 and used_tokens >= limit_tokens:
  37. logger.warning(
  38. "额度超限 user=%s day=%s used=%d limit=%d", user_name, day, used_tokens, limit_tokens
  39. )
  40. raise QuotaExceededError(user_name, day, quota)
  41. return quota
  42. def snapshot(user_name: str, usage_day: str | None = None) -> dict[str, Any]:
  43. """只读额度快照,不拦截(供 /api/usage/today 使用)。异常转为不可用标记。"""
  44. day = usage_day or db_conn.day_key()
  45. try:
  46. quota = billing_store.get_quota(user_name, day)
  47. usage = billing_store.usage_for_day(user_name, day)
  48. except Exception as exc:
  49. logger.warning("读取用量失败:%s: %s", type(exc).__name__, exc)
  50. return {
  51. "usage_day": day,
  52. "available": False,
  53. "error": f"{type(exc).__name__}: {exc}",
  54. }
  55. return {
  56. "usage_day": day,
  57. "available": True,
  58. "limit_tokens": quota.get("limit_tokens", 0),
  59. "used_tokens": quota.get("used_tokens", 0),
  60. "remaining_tokens": quota.get("remaining_tokens"),
  61. # total_tokens 与 used_tokens 同值,保留两个键是因为消费方用法不一:
  62. # 前端看「额度」用 used/limit,统计看「用量」用 total_tokens——与
  63. # billing_store.usage_for_day 的字段名对齐,避免同一个概念两个名字。
  64. "total_tokens": quota.get("used_tokens", 0),
  65. "requests": usage.get("requests", 0),
  66. "input_tokens": usage.get("input_tokens", 0),
  67. "output_tokens": usage.get("output_tokens", 0),
  68. "cost": usage.get("cost", 0.0),
  69. "default_limit": int(config.DAILY_TOKEN_LIMIT),
  70. }