meter.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. """用量入账。
  2. :class:`UsageMeter` 是**请求作用域**的:一次 /api/chat 请求建一个实例,
  3. 在 SSE 生成器的 finally 里调 ``settle()``。两条纪律:
  4. 1. **幂等**——``settle()`` 只生效一次。SSE 生成器有正常结束、异常、客户端
  5. 断连三条路径,全都汇到同一个 finally,不幂等就会重复扣费。
  6. 2. **归属时刻固定**——用量归属日取请求开始时算出的 ``usage_day``,不取
  7. ``settle()`` 执行时刻。否则跨零点的长请求会被算到第二天。
  8. """
  9. from __future__ import annotations
  10. import config
  11. from billing import pricing
  12. from db import billing_store, db_conn
  13. from obs import get_logger
  14. logger = get_logger(__name__)
  15. class UsageMeter:
  16. """一次请求的用量累加与入账。"""
  17. def __init__(
  18. self,
  19. user_name: str,
  20. session_id: str,
  21. kind: str,
  22. model: str,
  23. usage_day: str | None = None,
  24. trace_id: str = "",
  25. ) -> None:
  26. self.user_name = user_name
  27. self.session_id = session_id
  28. self.kind = kind
  29. self.model = model or config.MODEL
  30. # 归属日由请求开始时刻决定,故此处在构造时定死
  31. self.usage_day = usage_day or db_conn.day_key()
  32. self.trace_id = trace_id
  33. self.input_tokens = 0
  34. self.output_tokens = 0
  35. self.usage_source = "provider"
  36. self._settled = False
  37. self._usage_id: str | None = None
  38. # -------- 累加 --------
  39. def add(self, input_tokens: int, output_tokens: int, source: str = "provider") -> None:
  40. """累加一轮的 token。``source`` 为 provider | estimate。"""
  41. self.input_tokens += max(int(input_tokens), 0)
  42. self.output_tokens += max(int(output_tokens), 0)
  43. # 只要出现过估算,整条流水的口径就降级标注为 estimate,避免口径混杂
  44. if source == "estimate" and self.usage_source == "provider":
  45. self.usage_source = "estimate"
  46. # -------- 入账 --------
  47. @property
  48. def settled(self) -> bool:
  49. """是否已入账。"""
  50. return self._settled
  51. @property
  52. def total_tokens(self) -> int:
  53. return self.input_tokens + self.output_tokens
  54. def settle(self) -> dict:
  55. """把累计用量写入流水。重复调用直接返回上次结果。
  56. 计费失败不向上抛——请求已经跑完,计费问题不该让用户看到报错。
  57. 失败时记 ERROR 日志并把结果标为 ``settled=False``,留待对账。
  58. """
  59. if self._settled:
  60. return {"settled": True, "usage_id": self._usage_id, "duplicated": True}
  61. if self.total_tokens == 0:
  62. self._settled = True
  63. return {"settled": False, "reason": "no_usage"}
  64. try:
  65. cost, currency = pricing.compute_cost(
  66. self.model, self.input_tokens, self.output_tokens
  67. )
  68. except Exception as exc:
  69. logger.error(
  70. "计价失败未入账 user=%s model=%s tokens=%d/%d err=%s: %s",
  71. self.user_name, self.model, self.input_tokens, self.output_tokens,
  72. type(exc).__name__, exc,
  73. )
  74. self._settled = True
  75. return {"settled": False, "reason": "pricing_error", "error": str(exc)}
  76. try:
  77. usage_id = billing_store.record_usage(
  78. user_name=self.user_name,
  79. session_id=self.session_id,
  80. kind=self.kind,
  81. model=self.model,
  82. input_tokens=self.input_tokens,
  83. output_tokens=self.output_tokens,
  84. cost=cost,
  85. usage_day=self.usage_day,
  86. trace_id=self.trace_id,
  87. )
  88. except Exception as exc:
  89. logger.error(
  90. "入账失败 user=%s session=%s tokens=%d/%d err=%s: %s",
  91. self.user_name, self.session_id, self.input_tokens, self.output_tokens,
  92. type(exc).__name__, exc,
  93. )
  94. self._settled = True
  95. return {"settled": False, "reason": "store_error", "error": str(exc)}
  96. self._settled = True
  97. self._usage_id = usage_id
  98. logger.info(
  99. "入账完成 user=%s kind=%s model=%s tokens=%d/%d cost=%.6f%s source=%s",
  100. self.user_name, self.kind, self.model,
  101. self.input_tokens, self.output_tokens, cost, currency, self.usage_source,
  102. )
  103. return {
  104. "settled": True,
  105. "usage_id": usage_id,
  106. "input_tokens": self.input_tokens,
  107. "output_tokens": self.output_tokens,
  108. "cost": cost,
  109. "currency": currency,
  110. "usage_source": self.usage_source,
  111. }