"""失败埋点(telemetry)—— 与计费(usage.py)分家的另一类 usage_events 写入。 judged by kind:这里的四类 cost 恒 0、tokens 不入 kind=chat 汇总,语义是**可观测性 留痕**而非用户花销 —— 它们是 admin「工具失败聚集」面板与巡检邮件(core/toolfail.py) 的唯一持久数据源。此前与真计费(chat/image/video/vision)混住 usage.py,污染 「usage=计费」的心智模型(架构审查数据层 Top4),2026-07-23 拆出。 kind 常量是 loop/llm_transport(写)与 toolfail(读 SQL)之间的契约单一事实源 —— 以前两侧靠字符串字面量约定,改名会静默漏读。 """ from __future__ import annotations from decimal import Decimal from uuid import UUID from .engine import session_scope from .models import UsageEvent # ── kind 契约常量(写侧本模块 / 读侧 core/toolfail.py 共用)── KIND_TOOL_MALFORMED = "tool_malformed" KIND_TOOL_SALVAGED = "tool_salvaged" KIND_EMPTY_RESPONSE = "empty_response" KIND_RUN_ERROR = "run_error" def record_malformed_tool_call( *, task_id: UUID, user_id: UUID, model_profile: str, tool: str, arg_len: int, error: str, head: str, tail: str, tokens_in: int = 0, tokens_out: int = 0, ) -> None: """记一次被丢弃的畸形 tool_call(kind=tool_malformed)。 这不是记账而是留痕:畸形轮整轮丢弃、messages 无痕,此行是 admin「工具失败聚集」 面板与巡检邮件唯一的数据源(core/toolfail.py 第二段扫描按 units->>'tool'/'err' 聚合)。cost_cny 恒 0 —— cost 统计全 kind 合计且随任务展示,provider 抖动的浪费 不该算成用户花销;该轮真实 token 快照进 units,将来要算浪费成本可从 units 反推 (token 汇总只算 kind=chat,此处 tokens_in/out 不会混进 token 统计)。 """ with session_scope() as s: s.add(UsageEvent( user_id=user_id, task_id=task_id, message_id=None, # 畸形轮不入 messages,无可关联 kind=KIND_TOOL_MALFORMED, model_profile=model_profile, units={ "tool": tool, "len": int(arg_len), "err": str(error)[:200], "head": head, "tail": tail, "tokens_in": int(tokens_in), "tokens_out": int(tokens_out), }, cost_cny=Decimal("0"), )) def record_salvaged_tool_call( *, task_id: UUID, user_id: UUID, model_profile: str, tool: str, arg_len: int, salvaged_len: int, head: str, ) -> None: """记一次从畸形 arguments 里抢救成功的 tool_call(kind=tool_salvaged)。 与 record_malformed_tool_call 是一对:畸形轮本会整轮丢弃 + 非流式重 roll,salvage 命中时改成就地抠出尾部完好 JSON、当轮照常执行,省掉一次重试。此行是留痕(cost 恒 0, tokens 不入 kind=chat 汇总),供「工具失败聚集」面板 / 事后核对区分「真丢弃」与「抢救回」: arg_len=损坏前缀+JSON 的总长,salvaged_len=抠出 JSON 的长,差值即被丢弃的 wire 垃圾前缀长。 head 存前 300 字(过 ascii 转义,消费端编码不可控)供核验前缀形态是否仍是 char-0 型。 """ with session_scope() as s: s.add(UsageEvent( user_id=user_id, task_id=task_id, message_id=None, # 抢救出的 tool_call 正常执行并入 messages,但此留痕行不与之关联 kind=KIND_TOOL_SALVAGED, model_profile=model_profile, units={ "tool": tool, "len": int(arg_len), "salvaged_len": int(salvaged_len), "head": head, }, cost_cny=Decimal("0"), )) def record_empty_response( *, task_id: UUID, user_id: UUID, model_profile: str, attempt: int, tokens_in: int = 0, tokens_out: int = 0, finish_reason: str = "", ) -> None: """记一次 provider 吐空(kind=empty_response):assistant 轮既无 tool_calls 又无正文。 背景(task 2a1bc25d 案):unifyllm 网关对某档 Claude 偶发把 tool_use 漏成正文 / 直接吐空, run loop 见 tool_calls 空即当「模型答完」静默 done —— 无报错、run_status=idle,只能人肉 挖 DB 才发现。此行是留痕(cost 恒 0,tokens 不入 kind=chat 汇总):即使当轮非流式重试救回, 也留一条供「工具失败聚集」面板看到「哪个模型档在吐空」(第四段扫描,tool 名固定 "(empty)")。 attempt=第几次尝试(1=首个流式轮),tokens 快照供估算浪费。单条转瞬即逝不触发面板(阈值 min_count/min_tasks),跨 task 系统性吐空才冒头 —— 正是要抓的网关级抖动。 """ with session_scope() as s: s.add(UsageEvent( user_id=user_id, task_id=task_id, message_id=None, # 空响应轮不入 messages(丢弃重试),无可关联 kind=KIND_EMPTY_RESPONSE, model_profile=model_profile, units={ "attempt": int(attempt), "tokens_in": int(tokens_in), "tokens_out": int(tokens_out), # finish_reason 区分「网关 wire 吐空」(stop/其他)与「输出达上限被截断」 # (length)—— 后者是我方输出预算/推理失控,同上下文重试无效(见 loop 处理)。 "finish_reason": finish_reason or "", }, cost_cny=Decimal("0"), )) def record_run_error( *, task_id: UUID, user_id: UUID, model_profile: str, error: str, ) -> None: """记一次 run 级终态错误(kind=run_error)。 留痕而非记账(cost 恒 0):run 在 LLM 请求层/构建期直接抛异常(RateLimitError 余额不足、认证失败等)时,整轮没有 tool 消息,错误只写 tasks.run_error 一列 —— 而该列只留最后一次,下次成功即被清掉。此行是「工具失败聚集」面板 + 巡检邮件 看到 run 级错误的唯一持久数据源(core/toolfail.py 第三段扫描按 units->>'err' 聚合)。task 2a1bc25d 案:Zai 余额不足连挂 3 次续跑,前端无提示、admin 无感知。 """ with session_scope() as s: s.add(UsageEvent( user_id=user_id, task_id=task_id, message_id=None, # 错误轮通常无 assistant message 落库,无可关联 kind=KIND_RUN_ERROR, model_profile=model_profile, units={"err": str(error)[:500]}, cost_cny=Decimal("0"), ))