meter.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  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 json
  11. import config
  12. from billing import pricing
  13. from db import billing_store, db_conn
  14. from obs import get_logger
  15. logger = get_logger(__name__)
  16. class UsageMeter:
  17. """一次请求的用量累加与入账。"""
  18. def __init__(
  19. self,
  20. user_name: str,
  21. session_id: str,
  22. kind: str,
  23. model: str,
  24. usage_day: str | None = None,
  25. trace_id: str = "",
  26. usage_id: str | None = None,
  27. ) -> None:
  28. self.user_name = user_name
  29. self.session_id = session_id
  30. self.kind = kind
  31. self.model = model or config.MODEL
  32. # 归属日由请求开始时刻决定,故此处在构造时定死
  33. self.usage_day = usage_day or db_conn.day_key()
  34. self.trace_id = trace_id
  35. self.input_tokens = 0
  36. self.output_tokens = 0
  37. self.usage_source = "provider"
  38. self._settled = False
  39. self._usage_id = usage_id or db_conn.new_id()
  40. # -------- 累加 --------
  41. def add(self, input_tokens: int, output_tokens: int, source: str = "provider") -> None:
  42. """累加一轮的 token。``source`` 为 provider | estimate。"""
  43. self.input_tokens += max(int(input_tokens), 0)
  44. self.output_tokens += max(int(output_tokens), 0)
  45. # 只要出现过估算,整条流水的口径就降级标注为 estimate,避免口径混杂
  46. if source == "estimate" and self.usage_source == "provider":
  47. self.usage_source = "estimate"
  48. # -------- 入账 --------
  49. @property
  50. def settled(self) -> bool:
  51. """是否已入账。"""
  52. return self._settled
  53. @property
  54. def total_tokens(self) -> int:
  55. return self.input_tokens + self.output_tokens
  56. def settle(self) -> dict:
  57. """把累计用量写入流水。重复调用直接返回上次结果。
  58. 计费失败不向上抛——请求已经跑完,计费问题不该让用户看到报错。
  59. 失败时记 ERROR 日志并把结果标为 ``settled=False``,留待对账。
  60. """
  61. if self._settled:
  62. return {"settled": True, "usage_id": self._usage_id, "duplicated": True}
  63. if self.total_tokens == 0:
  64. self._settled = True
  65. return {"settled": False, "reason": "no_usage"}
  66. try:
  67. payload = {"user_name": self.user_name, "session_id": self.session_id,
  68. "kind": self.kind, "model": self.model, "usage_day": self.usage_day,
  69. "trace_id": self.trace_id, "usage_id": self._usage_id,
  70. "input_tokens": self.input_tokens, "output_tokens": self.output_tokens,
  71. "source": self.usage_source}
  72. with billing_store._usage_lock, db_conn.transaction():
  73. if not db_conn.query_one("SELECT id FROM billing_pending WHERE id = ?", (self._usage_id,)):
  74. db_conn.execute("INSERT INTO billing_pending (id, payload, created_at) VALUES (?, ?, ?)",
  75. (self._usage_id, json.dumps(payload), db_conn.fmt_dt()))
  76. except Exception:
  77. logger.exception("无法保存待结算记录 usage_id=%s", self._usage_id)
  78. return {"settled": False, "reason": "outbox_error"}
  79. try:
  80. cost, currency = pricing.compute_cost(
  81. self.model, self.input_tokens, self.output_tokens
  82. )
  83. except Exception as exc:
  84. logger.exception(
  85. "计价失败未入账 user=%s model=%s tokens=%d/%d err=%s: %s",
  86. self.user_name, self.model, self.input_tokens, self.output_tokens,
  87. type(exc).__name__, exc,
  88. )
  89. return {"settled": False, "reason": "pricing_error", "error": str(exc)}
  90. try:
  91. usage_id = billing_store.record_usage(
  92. user_name=self.user_name,
  93. session_id=self.session_id,
  94. kind=self.kind,
  95. model=self.model,
  96. input_tokens=self.input_tokens,
  97. output_tokens=self.output_tokens,
  98. cost=cost,
  99. usage_day=self.usage_day,
  100. trace_id=self.trace_id,
  101. usage_id=self._usage_id,
  102. )
  103. except Exception as exc:
  104. logger.exception(
  105. "入账失败 user=%s session=%s tokens=%d/%d err=%s: %s",
  106. self.user_name, self.session_id, self.input_tokens, self.output_tokens,
  107. type(exc).__name__, exc,
  108. )
  109. return {"settled": False, "reason": "store_error", "error": str(exc)}
  110. self._settled = True
  111. self._usage_id = usage_id
  112. try:
  113. db_conn.execute("DELETE FROM billing_pending WHERE id = ?", (usage_id,))
  114. except Exception:
  115. logger.exception("清理待结算记录失败,下次重试将按流水 ID 去重")
  116. logger.info(
  117. "入账完成 user=%s kind=%s model=%s tokens=%d/%d cost=%.6f%s source=%s",
  118. self.user_name, self.kind, self.model,
  119. self.input_tokens, self.output_tokens, cost, currency, self.usage_source,
  120. extra={"kv": {"event": "billing.settled", "usage_id": usage_id,
  121. "session_id": self.session_id, "cost": cost, "currency": currency}},
  122. )
  123. return {
  124. "settled": True,
  125. "usage_id": usage_id,
  126. "input_tokens": self.input_tokens,
  127. "output_tokens": self.output_tokens,
  128. "cost": cost,
  129. "currency": currency,
  130. "usage_source": self.usage_source,
  131. }
  132. def retry_pending() -> int:
  133. """重试持久化的结算;相同流水 ID 不会再次累计额度。"""
  134. settled = 0
  135. for row in db_conn.query("SELECT * FROM billing_pending ORDER BY created_at LIMIT 100"):
  136. try:
  137. payload = json.loads(row["payload"])
  138. inputs = payload.pop("input_tokens")
  139. outputs = payload.pop("output_tokens")
  140. source = payload.pop("source")
  141. meter = UsageMeter(**payload)
  142. meter.add(inputs, outputs, source)
  143. from obs import bind_trace_id, unbind_trace_id, log_context
  144. token = bind_trace_id(meter.trace_id)
  145. try:
  146. with log_context(run_id=meter._usage_id, session_id=meter.session_id, kind=meter.kind):
  147. settled += bool(meter.settle().get("settled"))
  148. finally:
  149. unbind_trace_id(token)
  150. except Exception:
  151. logger.exception("待结算记录恢复失败 id=%s", row["id"])
  152. return settled