guard.py 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  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. from billing import pricing
  32. pricing.compute_cost(config.MODEL, 0, 0)
  33. except Exception as exc:
  34. raise BillingConfigError(f"额度预检失败(计费库不可用):{type(exc).__name__}: {exc}") from exc
  35. limit_tokens = int(quota.get("limit_tokens") or 0)
  36. used_tokens = int(quota.get("used_tokens") or 0)
  37. # limit_tokens == 0 表示不限量(默认),此时只做读取不拦截
  38. if limit_tokens > 0 and used_tokens >= limit_tokens:
  39. logger.warning(
  40. "额度超限 user=%s day=%s used=%d limit=%d", user_name, day, used_tokens, limit_tokens
  41. )
  42. raise QuotaExceededError(user_name, day, quota)
  43. return quota
  44. def snapshot(user_name: str, usage_day: str | None = None) -> dict[str, Any]:
  45. """只读额度快照,不拦截(供 /api/usage/today 使用)。异常转为不可用标记。"""
  46. day = usage_day or db_conn.day_key()
  47. try:
  48. quota = billing_store.get_quota(user_name, day)
  49. usage = billing_store.usage_for_day(user_name, day)
  50. except Exception as exc:
  51. logger.warning("读取用量失败:%s: %s", type(exc).__name__, exc)
  52. return {
  53. "usage_day": day,
  54. "available": False,
  55. "error": f"{type(exc).__name__}: {exc}",
  56. }
  57. return {
  58. "usage_day": day,
  59. "available": True,
  60. "limit_tokens": quota.get("limit_tokens", 0),
  61. "used_tokens": quota.get("used_tokens", 0),
  62. "remaining_tokens": quota.get("remaining_tokens"),
  63. # total_tokens 与 used_tokens 同值,保留两个键是因为消费方用法不一:
  64. # 前端看「额度」用 used/limit,统计看「用量」用 total_tokens——与
  65. # billing_store.usage_for_day 的字段名对齐,避免同一个概念两个名字。
  66. "total_tokens": quota.get("used_tokens", 0),
  67. "requests": usage.get("requests", 0),
  68. "input_tokens": usage.get("input_tokens", 0),
  69. "output_tokens": usage.get("output_tokens", 0),
  70. "cost": usage.get("cost", 0.0),
  71. "default_limit": int(config.DAILY_TOKEN_LIMIT),
  72. }