| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- """用量入账。
- :class:`UsageMeter` 是**请求作用域**的:一次 /api/chat 请求建一个实例,
- 在 SSE 生成器的 finally 里调 ``settle()``。两条纪律:
- 1. **幂等**——``settle()`` 只生效一次。SSE 生成器有正常结束、异常、客户端
- 断连三条路径,全都汇到同一个 finally,不幂等就会重复扣费。
- 2. **归属时刻固定**——用量归属日取请求开始时算出的 ``usage_day``,不取
- ``settle()`` 执行时刻。否则跨零点的长请求会被算到第二天。
- """
- from __future__ import annotations
- import json
- import config
- from billing import pricing
- from db import billing_store, db_conn
- from obs import get_logger
- logger = get_logger(__name__)
- class UsageMeter:
- """一次请求的用量累加与入账。"""
- def __init__(
- self,
- user_name: str,
- session_id: str,
- kind: str,
- model: str,
- usage_day: str | None = None,
- trace_id: str = "",
- usage_id: str | None = None,
- ) -> None:
- self.user_name = user_name
- self.session_id = session_id
- self.kind = kind
- self.model = model or config.MODEL
- # 归属日由请求开始时刻决定,故此处在构造时定死
- self.usage_day = usage_day or db_conn.day_key()
- self.trace_id = trace_id
- self.input_tokens = 0
- self.output_tokens = 0
- self.usage_source = "provider"
- self._settled = False
- self._usage_id = usage_id or db_conn.new_id()
- # -------- 累加 --------
- def add(self, input_tokens: int, output_tokens: int, source: str = "provider") -> None:
- """累加一轮的 token。``source`` 为 provider | estimate。"""
- self.input_tokens += max(int(input_tokens), 0)
- self.output_tokens += max(int(output_tokens), 0)
- # 只要出现过估算,整条流水的口径就降级标注为 estimate,避免口径混杂
- if source == "estimate" and self.usage_source == "provider":
- self.usage_source = "estimate"
- # -------- 入账 --------
- @property
- def settled(self) -> bool:
- """是否已入账。"""
- return self._settled
- @property
- def total_tokens(self) -> int:
- return self.input_tokens + self.output_tokens
- def settle(self) -> dict:
- """把累计用量写入流水。重复调用直接返回上次结果。
- 计费失败不向上抛——请求已经跑完,计费问题不该让用户看到报错。
- 失败时记 ERROR 日志并把结果标为 ``settled=False``,留待对账。
- """
- if self._settled:
- return {"settled": True, "usage_id": self._usage_id, "duplicated": True}
- if self.total_tokens == 0:
- self._settled = True
- return {"settled": False, "reason": "no_usage"}
- try:
- payload = {"user_name": self.user_name, "session_id": self.session_id,
- "kind": self.kind, "model": self.model, "usage_day": self.usage_day,
- "trace_id": self.trace_id, "usage_id": self._usage_id,
- "input_tokens": self.input_tokens, "output_tokens": self.output_tokens,
- "source": self.usage_source}
- with billing_store._usage_lock, db_conn.transaction():
- if not db_conn.query_one("SELECT id FROM billing_pending WHERE id = ?", (self._usage_id,)):
- db_conn.execute("INSERT INTO billing_pending (id, payload, created_at) VALUES (?, ?, ?)",
- (self._usage_id, json.dumps(payload), db_conn.fmt_dt()))
- except Exception:
- logger.exception("无法保存待结算记录 usage_id=%s", self._usage_id)
- return {"settled": False, "reason": "outbox_error"}
- try:
- cost, currency = pricing.compute_cost(
- self.model, self.input_tokens, self.output_tokens
- )
- except Exception as exc:
- logger.exception(
- "计价失败未入账 user=%s model=%s tokens=%d/%d err=%s: %s",
- self.user_name, self.model, self.input_tokens, self.output_tokens,
- type(exc).__name__, exc,
- )
- return {"settled": False, "reason": "pricing_error", "error": str(exc)}
- try:
- usage_id = billing_store.record_usage(
- user_name=self.user_name,
- session_id=self.session_id,
- kind=self.kind,
- model=self.model,
- input_tokens=self.input_tokens,
- output_tokens=self.output_tokens,
- cost=cost,
- usage_day=self.usage_day,
- trace_id=self.trace_id,
- usage_id=self._usage_id,
- )
- except Exception as exc:
- logger.exception(
- "入账失败 user=%s session=%s tokens=%d/%d err=%s: %s",
- self.user_name, self.session_id, self.input_tokens, self.output_tokens,
- type(exc).__name__, exc,
- )
- return {"settled": False, "reason": "store_error", "error": str(exc)}
- self._settled = True
- self._usage_id = usage_id
- try:
- db_conn.execute("DELETE FROM billing_pending WHERE id = ?", (usage_id,))
- except Exception:
- logger.exception("清理待结算记录失败,下次重试将按流水 ID 去重")
- if pricing.enabled():
- logger.info(
- "入账完成 user=%s kind=%s model=%s tokens=%d/%d cost=%.6f%s source=%s",
- self.user_name, self.kind, self.model,
- self.input_tokens, self.output_tokens, cost, currency, self.usage_source,
- extra={"kv": {"event": "billing.settled", "usage_id": usage_id,
- "session_id": self.session_id, "cost": cost, "currency": currency}})
- else:
- logger.info('Token 用量已记录', extra={'kv': {'event': 'usage.recorded',
- 'usage_id': usage_id, 'session_id': self.session_id, 'model': self.model,
- 'input_tokens': self.input_tokens, 'output_tokens': self.output_tokens,
- 'billing_enabled': False}})
- return {
- "settled": True,
- "usage_id": usage_id,
- "input_tokens": self.input_tokens,
- "output_tokens": self.output_tokens,
- "cost": cost,
- "currency": currency,
- "usage_source": self.usage_source,
- "billing_enabled": pricing.enabled(),
- }
- def retry_pending() -> int:
- """重试持久化的结算;相同流水 ID 不会再次累计额度。"""
- settled = 0
- for row in db_conn.query("SELECT * FROM billing_pending ORDER BY created_at LIMIT 100"):
- try:
- payload = json.loads(row["payload"])
- inputs = payload.pop("input_tokens")
- outputs = payload.pop("output_tokens")
- source = payload.pop("source")
- meter = UsageMeter(**payload)
- meter.add(inputs, outputs, source)
- from obs import bind_trace_id, unbind_trace_id, log_context
- token = bind_trace_id(meter.trace_id)
- try:
- with log_context(run_id=meter._usage_id, session_id=meter.session_id, kind=meter.kind):
- settled += bool(meter.settle().get("settled"))
- finally:
- unbind_trace_id(token)
- except Exception:
- logger.exception("待结算记录恢复失败 id=%s", row["id"])
- return settled
|