fix(pricing): correct domestic model cost accounting

This commit is contained in:
caoqianming 2026-08-30 11:37:12 +08:00
parent 7ffa9eb645
commit 08754be1a4
18 changed files with 756 additions and 64 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- 国内模型成本统计跟随最新公开价格更新DeepSeek 会按实际峰谷时段和缓存命中量计算,历史调价后的记录也可审计矫正。
- GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。
- 删除已发布文件后会释放个人存储配额;管理员可分别查看用户当前文件、隐藏回收站及物理总占用。

View File

@ -244,6 +244,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
- working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。
- `auto_title_pending`(0023)只是一轮 UI 命名闸,不是 task 状态机;旧创建入口/存量行恒 false快速入口首发后消费人工改名优先清闸。
- **0004 简化**:runs 表只写不读、独立 run 实体在单活形态下冗余 → 合并 `run_status/run_error` 入 tasks需要前端关联本轮时直接复用该轮 user message UUID 为 `run_id`,不恢复 runs 表。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok 收回 idle,error(出错)与 cancelled(用户停止)是持久终态 —— 前端 `renderPersistedRunTerminal` 据此在每次重渲后补持久卡(扛过收尾 loadMessages 整屏重建),刷新/切任务仍在;下次起新 run(post_message 写 running)覆盖清掉。
- **版本化模型计费**(2026-08-30):provider 响应的 usage token 是用量事实源,直连模型 YAML 中带生效时间/币种/来源的 `pricing.periods` 是价格事实源LiteLLM price map 仅给没有本地价目的网关模型兜底,避免第三方价目滞后覆盖已核对的国内厂商价格。`core/pricing.py` 按调用时间选择价格版本和 DeepSeek 峰谷时段,统一拆缓存命中/未命中/输出成本;主对话、标题、折叠、知识库摘要与 prompt 润色共用 `record_chat_usage`。每行 `usage_events.units` 快照 revision/tier/原币单价/汇率/分项成本,无 schema migration公开按量价是产品估算口径赠送额度、套餐和商务折扣以 provider 账单为最终财务口径。历史调价只通过默认 dry-run、显式 apply 且保存旧值的重算工具修正,不让新配置静默改写旧账。
- **0029 消息序号**:`tasks.next_message_idx` 在 task 行锁下统一分配 `messages.idx`Web、agent 与渠道追加不再各自维护序号或依赖冲突重试;分配时仍与 `max(idx)` 校准,允许蓝绿发布窗口内旧实例继续写入。清空消息与计数器在同一事务归零。
- **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。
- **文件面板先备料**:user_root 与普通目录都可显式新建直接子目录;创建成功后前端进入该目录,用户可先上传/选入资料,再把顶层空目录选作新对话 working_dir。目录 leaf 复用 `validate_task_name`,不允许借 UI 创建点目录或路径式名称。
@ -526,5 +527,5 @@ ANSYS 能力固定面向 Windows 上的 Mechanical 2024 R2revision 242
## 附录:DeepSeek V4 关键事实(2026-04-24)
- V4-Pro:1.6T/49B 激活,1M context,SWE-Bench 80.6;V4-Flash:284B/13B 激活,1M context
- 推理:non-thinking / thinking / thinking-max;价格 in ~$0.145/M、out ~$1.74/M(约 Opus 1/6)
- 推理:non-thinking / thinking / thinking-max2026-08-16 起按工作日 UTC 峰谷计费,具体单价以官方价格页与版本化模型档案为准
- 旧 `deepseek-chat/reasoner` 已于 2026-07 下线,全库仅存 `deepseek-v4-flash/pro`

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-27(GLM-5.3 Flash、原生图片输入与 reasoning 生命周期统一;未发版)
最后更新:2026-08-30(国内模型版本化价格、DeepSeek 峰谷计费与历史重算;未发版)
---
@ -20,6 +20,8 @@
---
## 已完成关键能力
- **08-30 / Unreleased / 国内模型版本化计费与历史矫正工具**:新增本地价格目录解析,直连 DeepSeek、豆包、智谱不再被 LiteLLM 滞后价目覆盖DeepSeek 按 2026-08-16 官方新价及工作日 UTC 峰谷窗口计费豆包使用人民币固定价GLM 绑定国内 BigModel 标准按量价。主对话、Prompt 润色、自动标题、上下文折叠和知识库摘要统一提取缓存 usage 并保存价格 revision/tier/币种/汇率/分项快照;新增默认 dry-run、显式确认 apply/rollback 的历史重算脚本,可针对调价后 DeepSeek 事件审计并矫正成本。无 schema/migration未连接或写入生产数据库。
- **08-27 / Unreleased / GLM-5.3 Flash + 通用 reasoning/多模态能力**:下架 GLM-5.1/5.2 的可选入口并以隐藏别名将存量 `glm.pro/pro52` 统一解析到 `glm.flash53`Flash53 加入默认与专业档位;模型能力新增原生输入模态和 `none/tool_turn/conversation/provider_managed` reasoning 生命周期历史状态仅向同一生产模型回放DeepSeek 限当前工具轮、GLM 保留同模型会话并发送 `clear_thinking=false`。结构化图片附件仍以文件引用为事实源,只在 provider 请求边界安全物化为 Base64 `image_url`GLM 不再注册 `look_at_image`,文档读取与生图/视频工具保持独立;无 schema/migration/依赖变化,未调用真实模型或连接生产数据库。
- **08-27 / 0.69.1 / 专业软件结果轨与终态收敛**:当前对话的专业软件 Job 按独立状态点堆叠在聊天左侧,悬停展示名称与进度,成功的 Workspace 任务可直接打开多预览画廊,移动端收敛为“软件结果”入口;主消息流只保留用户与 Agent 叙事,默认 `report` 完成后只更新 Job 账本和视图,显式 `analyze` 才重新进入 Agent 对话。Web SSE 终态统一延后到任务状态落库之后发送,结果刷新会清理不再活动的 live card避免软件已出图但“思考中”被重新点亮。既有历史软件报告消息继续兼容显示无 schema、migration 或 HTTP API 变化。

2
RUN.md
View File

@ -1021,6 +1021,8 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| `.pptx` 预览首次慢几秒 | 正常 —— soffice 冷启 + 转换 ~2-4s,转完缓存到源同目录 `.preview/<stem>.<hash>.pdf`,再点即时。源文件一改(mtime/size 变)hash 变、自动重转 |
| `materials_project_*` tool 没出现在对话里 | `.env` 没设 `MP_API_KEY`,build_agent 跳过注册。设了重启 web 即可;Materials Project 联网查询走 host-side tool,离线 pymatgen 不受影响。 |
| 豆包调价了 | 改 `config/media/doubao.yaml``price_cny_per_image` 一行 → 重启 web。**历史 usage_events 不受影响**(units jsonb 里有当时单价 snapshot,聚合查仍按旧价);新写入按新价。涨价瞬间到改 YAML 中间这段记账偏低,开发期接受 |
| 文本模型调价了 | 在 `config/models/<family>.yaml``pricing.periods` 追加带 `revision/effective_from/source_url` 的新价格版本并重启 web直连模型以本地版本化价格为准LiteLLM 只兜底无本地价格的网关。新事件会在 `usage_events.units` 快照版本、时段、币种、汇率和分项成本,历史记录不会被配置静默改写。 |
| DeepSeek 调价后的历史成本不准 | 先显式设置目标库 `ZCBOT_DB_URL`,运行 `.venv/Scripts/python.exe scripts/reprice_usage.py --from 2026-08-16T16:00:00Z` 查看 dry-run核对脱敏 DB 目标、行数和差额后,才追加 `--apply --confirm APPLY_DEEPSEEK_REPRICE`。脚本单事务、按 revision 幂等,并保存 `previous_cost_cny`;回退用相同参数加 `--rollback --confirm APPLY_DEEPSEEK_REPRICE`。缺缓存明细的旧辅助调用按未命中估算并标记。 |
| `kill -HUP <pid>``/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` |
| `systemctl restart zcbot` 要等几十秒才退 | 正常 —— 优雅 drain 在等在跑的 run 收尾(`shutdown.drain_timeout` 默 30s),没在跑 run 时秒退。journal 出现 `[shutdown] draining N in-flight run(s)` 即正常。真急(不在乎杀掉在跑 run):`systemctl kill -s KILL zcbot` |
| 部署后在跑的对话被标 `error: server restarted before run finished` | 该 run 在 drain 期内没收尾、cancel 也没在 `cancel_grace` 内退,被 SIGKILL 后下次启动 reaper 标的。多半是 run 卡在不 poll cancel 的长动作(如单次超长 docker exec)或 `TimeoutStopSec` 配得比 drain 预算还小被提前 SIGKILL。先核对 unit `TimeoutStopSec > drain_timeout + cancel_grace`;真有超长 run 把 `drain_timeout` 调大 |

View File

@ -23,10 +23,32 @@ variants:
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
# Flash-0731 官方美元价按 1 USD ~= 7.2 CNY 折算;峰谷价正式生效后再同步。
input_cny_per_mtoken: 1.0
output_cny_per_mtoken: 2.0
cache_hit_cny_per_mtoken: 0.02 # 官方 $0.0028 / M tokens
# 扁平价保留给旧调用方,取当前谷时折算值;正式记账使用下面的版本化 pricing。
input_cny_per_mtoken: 1.584
output_cny_per_mtoken: 4.752
cache_hit_cny_per_mtoken: 0.0504
pricing:
periods:
- revision: deepseek-v4-20260816
currency: USD
fx_to_cny: 7.2
source_url: https://api-docs.deepseek.com/quick_start/pricing/
checked_at: 2026-08-30
effective_from: 2026-08-16T16:00:00Z
default: # 谷时(官方峰时之外)
input_per_mtoken: 0.22
cache_hit_per_mtoken: 0.007
output_per_mtoken: 0.66
time_tiers:
- name: peak
timezone: UTC
weekdays: [mon, tue, wed, thu, fri]
windows:
- ["01:00", "04:00"]
- ["06:00", "10:00"]
input_per_mtoken: 0.44
cache_hit_per_mtoken: 0.014
output_per_mtoken: 1.32
pro:
display_name: DeepSeek V4 Pro
@ -49,6 +71,28 @@ variants:
optimal_temperature: 0.2
prompt_caching: false
extended_thinking: false
input_cny_per_mtoken: 2.0
output_cny_per_mtoken: 20.0
cache_hit_cny_per_mtoken: 0.2 # DeepSeek 前缀缓存命中价(input 的 ~0.1x)
input_cny_per_mtoken: 4.752
output_cny_per_mtoken: 14.256
cache_hit_cny_per_mtoken: 0.1584
pricing:
periods:
- revision: deepseek-v4-20260816
currency: USD
fx_to_cny: 7.2
source_url: https://api-docs.deepseek.com/quick_start/pricing/
checked_at: 2026-08-30
effective_from: 2026-08-16T16:00:00Z
default:
input_per_mtoken: 0.66
cache_hit_per_mtoken: 0.022
output_per_mtoken: 1.98
time_tiers:
- name: peak
timezone: UTC
weekdays: [mon, tue, wed, thu, fri]
windows:
- ["01:00", "04:00"]
- ["06:00", "10:00"]
input_per_mtoken: 1.32
cache_hit_per_mtoken: 0.044
output_per_mtoken: 3.96

View File

@ -32,6 +32,17 @@ variants:
input_cny_per_mtoken: 3.0
output_cny_per_mtoken: 15.0
cache_hit_cny_per_mtoken: 0.6
pricing:
periods:
- revision: doubao-seed21-20260628
currency: CNY
source_url: https://www.volcengine.com/product/ark
checked_at: 2026-08-30
effective_from: 2026-06-28T00:00:00Z
default:
input_per_mtoken: 3.0
cache_hit_per_mtoken: 0.6
output_per_mtoken: 15.0
pro:
display_name: 豆包 Seed 2.1 Pro
@ -56,6 +67,17 @@ variants:
input_cny_per_mtoken: 6.0
output_cny_per_mtoken: 30.0
cache_hit_cny_per_mtoken: 1.2
pricing:
periods:
- revision: doubao-seed21-20260628
currency: CNY
source_url: https://www.volcengine.com/product/ark
checked_at: 2026-08-30
effective_from: 2026-06-28T00:00:00Z
default:
input_per_mtoken: 6.0
cache_hit_per_mtoken: 1.2
output_per_mtoken: 30.0
evolving:
# 自进化版:统一 model_id `doubao-seed-evolving`,每周至少迭代一次,始终指向最新版。
@ -83,3 +105,14 @@ variants:
input_cny_per_mtoken: 6.0
output_cny_per_mtoken: 30.0
cache_hit_cny_per_mtoken: 1.2
pricing:
periods:
- revision: doubao-seed-evolving-202607
currency: CNY
source_url: https://www.volcengine.com/product/ark
checked_at: 2026-08-30
effective_from: 2026-07-01T00:00:00Z
default:
input_per_mtoken: 6.0
cache_hit_per_mtoken: 1.2
output_per_mtoken: 30.0

View File

@ -27,10 +27,22 @@ variants:
optimal_temperature: 1.0
prompt_caching: false
extended_thinking: false
# 官方国际站标价按 1 USD ~= 7.2 CNY 折算;促销价不写入长期兜底。
input_cny_per_mtoken: 1.08
output_cny_per_mtoken: 3.6
cache_hit_cny_per_mtoken: 0.216
# 当前 endpoint 是国内 BigModel使用国内按量标准价限时活动/套餐抵扣不进入
# 长期公开价估算,实际财务扣费仍以账号费用中心账单为准。
input_cny_per_mtoken: 0.8
output_cny_per_mtoken: 2.8
cache_hit_cny_per_mtoken: 0.23
pricing:
periods:
- revision: bigmodel-glm53-flash-20260826
currency: CNY
source_url: https://bigmodel.cn/pricing
checked_at: 2026-08-30
effective_from: 2026-08-26T00:00:00Z
default:
input_per_mtoken: 0.8
cache_hit_per_mtoken: 0.23
output_per_mtoken: 2.8
# 隐藏兼容别名:真实调用、能力和记账身份全部归 glm.flash53。
# 待一个兼容版本确认无旧 profile 活跃后删除。

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import List, Optional
from typing import Any, Dict, List, Optional
import yaml
@ -75,6 +75,9 @@ class ModelCapabilities:
# 前缀缓存命中价(DeepSeek 等自动缓存 prompt 前缀,命中部分按此价,通常 ~0.1x input)。
# 0 = 不区分,缓存命中按 input 全价记(安全兜底,不会少记)。
cache_hit_cny_per_mtoken: float = 0.0
# 版本化价格配置。存在时由 core.pricing 作为本地事实源;上面三个扁平字段仅作
# 旧档案/旧调用方兼容与无版本价格兜底。
pricing: Dict[str, Any] = field(default_factory=dict)
# API 接入
api_base: str = ""

View File

@ -34,6 +34,7 @@ from .context import (
filter_reasoning_for_replay,
prepare_messages_with_stats,
)
from .llm_transport import extract_usage_details
from .storage import session_scope
from .storage.models import Task
from .storage.usage import record_chat_usage
@ -182,17 +183,24 @@ def maybe_fold(
emit({"type": "warn", "msg": "context fold: 摘要调用返回空,本轮跳过折叠"})
return None
usage = getattr(response, "usage", None)
usage = extract_usage_details(getattr(response, "usage", None))
try:
record_chat_usage(
task_id=session.task_id,
user_id=user_id,
message_id=None, # 摘要不入 messages,event 挂 task 维度
model_profile=f"{caps.family}.{caps.variant}",
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
prompt_tokens=usage["tokens_in"],
completion_tokens=usage["tokens_out"],
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
cache_hit_tokens=usage["cache_hit_tokens"],
cache_hit_cny_per_mtoken=getattr(caps, "cache_hit_cny_per_mtoken", 0.0),
pricing=getattr(caps, "pricing", {}) or {},
extra_units={
k: v for k, v in usage.items()
if k not in ("tokens_in", "tokens_out") and v
},
response=response,
# 独立 kind:按 kind 对账"折叠烧多少钱",且折叠次数 = count(该 kind)
# (任务详情 context_folds 字段的数据源,免加计数列)

View File

@ -30,6 +30,7 @@ from core.kb import format_index_line, kb_dir, parse_index, pending_sources
from core.file_store import atomic_write_text
from core.kb_lock import KbBusyError, kb_is_locked, kb_mutation_lock
from core.llm import LLM
from core.llm_transport import extract_usage_details
from core.storage.usage import record_chat_usage, record_vision_usage
# 摘要模型固定走最便宜档(与 web FALLBACK_MODEL_PROFILE 同值;单次几千 token,不随任务模型)
@ -184,20 +185,30 @@ def _summarize(
except Exception as e:
print(f"[kb_ingest] summary llm failed ({filename}): {type(e).__name__}: {e}", flush=True)
return fallback
usage = getattr(response, "usage", None)
usage = extract_usage_details(getattr(response, "usage", None))
try:
record_chat_usage(
task_id=None,
user_id=user_id,
message_id=None,
model_profile=f"{caps.family}.{caps.variant}",
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
prompt_tokens=usage["tokens_in"],
completion_tokens=usage["tokens_out"],
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
cache_hit_tokens=usage["cache_hit_tokens"],
cache_hit_cny_per_mtoken=getattr(caps, "cache_hit_cny_per_mtoken", 0.0),
pricing=getattr(caps, "pricing", {}) or {},
response=response,
kind="kb_ingest",
extra_units={"kb": kb_name, "source": filename},
extra_units={
"kb": kb_name,
"source": filename,
**{
k: v for k, v in usage.items()
if k not in ("tokens_in", "tokens_out") and v
},
},
)
except Exception:
pass

View File

@ -340,6 +340,7 @@ class AgentLoop:
output_cny_per_mtoken=self.caps.output_cny_per_mtoken,
cache_hit_tokens=usage_details["cache_hit_tokens"],
cache_hit_cny_per_mtoken=self.caps.cache_hit_cny_per_mtoken,
pricing=self.caps.pricing,
extra_units={
k: v for k, v in usage_details.items()
if k not in ("tokens_in", "tokens_out") and v

209
core/pricing.py Normal file
View File

@ -0,0 +1,209 @@
"""版本化 chat 价格解析与成本计算。
模型 API 通常只返回 token usage不返回实际金额这里以模型 YAML 中经过核对的
provider 价格为事实源并把命中的价格版本时段和汇率快照交给 usage_events 留痕
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, time, timezone
from decimal import Decimal
from typing import Any, Mapping, Optional
_MTOKEN = Decimal("1000000")
_WEEKDAYS = {
"mon": 0,
"tue": 1,
"wed": 2,
"thu": 3,
"fri": 4,
"sat": 5,
"sun": 6,
}
def _decimal(value: Any, default: str = "0") -> Decimal:
if value is None or value == "":
return Decimal(default)
return Decimal(str(value))
def _parse_datetime(value: Any) -> Optional[datetime]:
if not value:
return None
if isinstance(value, datetime):
dt = value
else:
raw = str(value).strip().replace("Z", "+00:00")
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _parse_time(value: str) -> time:
hour, minute = (int(part) for part in value.split(":", 1))
return time(hour=hour, minute=minute)
def _in_window(now: time, start: time, end: time) -> bool:
if start <= end:
return start <= now < end
return now >= start or now < end
@dataclass(frozen=True)
class PriceQuote:
revision: str
source_url: str
currency: str
fx_to_cny: Decimal
tier: str
input_per_mtoken: Decimal
output_per_mtoken: Decimal
cache_hit_per_mtoken: Decimal
@dataclass(frozen=True)
class CostBreakdown:
total_cny: Decimal
input_cny: Decimal
cache_hit_cny: Decimal
output_cny: Decimal
cache_hit_tokens: int
cache_miss_tokens: int
def _periods(pricing: Mapping[str, Any]) -> list[Mapping[str, Any]]:
periods = pricing.get("periods")
if isinstance(periods, list):
return [p for p in periods if isinstance(p, Mapping)]
# 兼容一个价格版本直接写在 pricing 顶层的简写。
return [pricing]
def _select_period(
pricing: Mapping[str, Any], occurred_at: datetime
) -> Optional[Mapping[str, Any]]:
candidates: list[tuple[datetime, Mapping[str, Any]]] = []
for period in _periods(pricing):
start = _parse_datetime(period.get("effective_from")) or datetime.min.replace(
tzinfo=timezone.utc
)
end = _parse_datetime(period.get("effective_to"))
if start <= occurred_at and (end is None or occurred_at < end):
candidates.append((start, period))
if not candidates:
return None
return max(candidates, key=lambda item: item[0])[1]
def _tier_matches(tier: Mapping[str, Any], occurred_at: datetime) -> bool:
tz_name = str(tier.get("timezone") or "UTC").upper()
if tz_name != "UTC":
raise ValueError(f"pricing time tier 暂只支持 UTC收到 {tz_name!r}")
now = occurred_at.astimezone(timezone.utc)
weekdays = tier.get("weekdays") or []
if weekdays:
allowed = {_WEEKDAYS[str(day).lower()] for day in weekdays}
if now.weekday() not in allowed:
return False
windows = tier.get("windows") or []
if not windows:
return True
current = now.time().replace(tzinfo=None)
return any(
_in_window(current, _parse_time(window[0]), _parse_time(window[1]))
for window in windows
if isinstance(window, (list, tuple)) and len(window) == 2
)
def resolve_chat_price(
pricing: Mapping[str, Any] | None,
*,
occurred_at: Optional[datetime] = None,
) -> Optional[PriceQuote]:
"""解析调用时刻适用的价格;无配置或尚未生效时返回 ``None``。"""
if not pricing:
return None
when = occurred_at or datetime.now(timezone.utc)
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
when = when.astimezone(timezone.utc)
period = _select_period(pricing, when)
if period is None:
return None
rates = period.get("default") or {}
tier_name = "default"
for tier in period.get("time_tiers") or []:
if isinstance(tier, Mapping) and _tier_matches(tier, when):
rates = tier
tier_name = str(tier.get("name") or "time_tier")
break
currency = str(period.get("currency") or "CNY").upper()
if currency not in {"CNY", "USD"}:
raise ValueError(f"不支持的 pricing currency: {currency!r}")
fx = _decimal(period.get("fx_to_cny"), "1") if currency == "USD" else Decimal("1")
return PriceQuote(
revision=str(period.get("revision") or "unversioned"),
source_url=str(period.get("source_url") or ""),
currency=currency,
fx_to_cny=fx,
tier=tier_name,
input_per_mtoken=_decimal(rates.get("input_per_mtoken")),
output_per_mtoken=_decimal(rates.get("output_per_mtoken")),
cache_hit_per_mtoken=_decimal(
rates.get("cache_hit_per_mtoken"),
str(rates.get("input_per_mtoken") or 0),
),
)
def calculate_chat_cost(
quote: PriceQuote,
*,
prompt_tokens: int,
completion_tokens: int,
cache_hit_tokens: int = 0,
) -> CostBreakdown:
"""按价格快照拆分缓存命中、未命中和输出成本,返回人民币金额。"""
tokens_in = max(0, int(prompt_tokens))
hit = max(0, min(int(cache_hit_tokens), tokens_in))
miss = tokens_in - hit
tokens_out = max(0, int(completion_tokens))
input_cost = Decimal(miss) * quote.input_per_mtoken * quote.fx_to_cny / _MTOKEN
hit_cost = Decimal(hit) * quote.cache_hit_per_mtoken * quote.fx_to_cny / _MTOKEN
output_cost = Decimal(tokens_out) * quote.output_per_mtoken * quote.fx_to_cny / _MTOKEN
quant = Decimal("0.000001")
return CostBreakdown(
total_cny=(input_cost + hit_cost + output_cost).quantize(quant),
input_cny=input_cost.quantize(quant),
cache_hit_cny=hit_cost.quantize(quant),
output_cny=output_cost.quantize(quant),
cache_hit_tokens=hit,
cache_miss_tokens=miss,
)
def pricing_snapshot(quote: PriceQuote, breakdown: CostBreakdown) -> dict[str, Any]:
"""生成可直接合并进 usage_events.units 的价格快照。"""
return {
"pricing_revision": quote.revision,
"pricing_source": "local_catalog",
"pricing_source_url": quote.source_url,
"pricing_currency": quote.currency,
"price_tier": quote.tier,
"fx_to_cny": float(quote.fx_to_cny),
"input_price_per_mtoken": float(quote.input_per_mtoken),
"cache_hit_price_per_mtoken": float(quote.cache_hit_per_mtoken),
"output_price_per_mtoken": float(quote.output_per_mtoken),
"cache_hit_tokens": breakdown.cache_hit_tokens,
"cache_miss_tokens": breakdown.cache_miss_tokens,
"input_cost_cny": float(breakdown.input_cny),
"cache_hit_cost_cny": float(breakdown.cache_hit_cny),
"output_cost_cny": float(breakdown.output_cny),
}

View File

@ -3,19 +3,20 @@
chat 类型的入口由 loop.py assistant message 入库后调用;媒体工具(image/video/audio)
tool execute 完后由 tool 直接调用对应入口(record_image_usage )
币种(0007):全表统一 CNY(`cost_cny` )chat 路径走 litellm USD cost_map 内部
×USD_TO_CNY 折算落库;媒体路径价格本身就是 CNY,直接落units jsonb snapshot 当时
的关键价格参数(chat 没有,media price_cny_per_image ),便于跨调价对账
币种(0007):全表统一 CNY(`cost_cny` )有版本化本地价格的直连模型以该配置为
事实源未配置的网关模型才走 LiteLLM USD cost map再退到扁平人民币价格
units jsonb 快照当时价格版本时段币种汇率和分项成本便于跨调价对账
"""
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Mapping, Optional
from uuid import UUID
from sqlalchemy import func, select, update
from ..pricing import calculate_chat_cost, pricing_snapshot, resolve_chat_price
from .engine import session_scope
from .models import Message, UsageEvent
@ -88,6 +89,8 @@ def record_chat_usage(
output_cny_per_mtoken: float = 0.0,
cache_hit_tokens: int = 0,
cache_hit_cny_per_mtoken: float = 0.0,
pricing: Optional[Mapping[str, Any]] = None,
occurred_at: Optional[datetime] = None,
extra_units: Optional[Mapping[str, Any]] = None,
response: Any = None,
kind: str = "chat",
@ -103,9 +106,32 @@ def record_chat_usage(
可挂,溯源靠 kind + extra_units(kb_ingest {"kb": 库名, "source": 原件名})
返回算出的 cost_cny(已落库),调用方可用作 SSE 显示
"""
# 直连 provider 的版本化本地价格是事实源,避免 LiteLLM 内置价目滞后覆盖已核对
# 的厂商价格。无本地适用价格的旧档案/网关模型才沿用 LiteLLM → 扁平价兜底。
when = occurred_at
if when is None:
created = getattr(response, "created", None)
try:
when = datetime.fromtimestamp(float(created), tz=timezone.utc) if created else None
except (TypeError, ValueError, OSError):
when = None
when = when or datetime.now(timezone.utc)
quote = resolve_chat_price(pricing, occurred_at=when)
price_units: dict[str, Any] = {}
if quote is not None:
breakdown = calculate_chat_cost(
quote,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cache_hit_tokens=cache_hit_tokens,
)
cost_cny = breakdown.total_cny
price_units = pricing_snapshot(quote, breakdown)
price_units["pricing_at"] = when.astimezone(timezone.utc).isoformat()
else:
cost_usd = _safe_chat_cost_usd(response)
cost_cny = (cost_usd * USD_TO_CNY).quantize(Decimal("0.000001"))
if cost_cny == 0 and (input_cny_per_mtoken or output_cny_per_mtoken):
if quote is None and cost_cny == 0 and (input_cny_per_mtoken or output_cny_per_mtoken):
cost_cny = _fallback_chat_cost_cny(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
@ -114,19 +140,23 @@ def record_chat_usage(
cache_hit_tokens=cache_hit_tokens,
cache_hit_cny_per_mtoken=cache_hit_cny_per_mtoken,
)
units = {
units: dict[str, Any] = {
"tokens_in": int(prompt_tokens),
"tokens_out": int(completion_tokens),
# snapshot 折算系数,便于历史对账(汇率/价格涨跌后仍能还原当时折算逻辑)
"usd_to_cny": float(USD_TO_CNY),
"cache_hit_tokens": max(0, min(int(cache_hit_tokens), int(prompt_tokens))),
}
if quote is None:
# LiteLLM cost map 是 USD快照折算系数供旧路径历史对账。
units["usd_to_cny"] = float(USD_TO_CNY)
if input_cny_per_mtoken or output_cny_per_mtoken:
units.update({
"input_cny_per_mtoken": float(input_cny_per_mtoken or 0),
"output_cny_per_mtoken": float(output_cny_per_mtoken or 0),
"cache_hit_cny_per_mtoken": float(cache_hit_cny_per_mtoken or 0),
})
if extra_units:
units.update(extra_units)
units.update(price_units)
with session_scope() as s:
s.add(UsageEvent(

View File

@ -15,6 +15,7 @@ from sqlalchemy import select, update
from .agent_builder import ROOT, load_config
from .capabilities import ModelCapabilities
from .llm import LLM
from .llm_transport import extract_usage_details
from .storage import session_scope
from .storage.models import Task
from .storage.usage import record_chat_usage
@ -127,17 +128,24 @@ def generate_task_title(
)
applied = bool(getattr(result, "rowcount", 0))
if response is not None and caps is not None:
usage = getattr(response, "usage", None)
usage = extract_usage_details(getattr(response, "usage", None))
try:
record_chat_usage(
task_id=task_id,
user_id=user_id,
message_id=None,
model_profile=f"{caps.family}.{caps.variant}",
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
prompt_tokens=usage["tokens_in"],
completion_tokens=usage["tokens_out"],
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
cache_hit_tokens=usage["cache_hit_tokens"],
cache_hit_cny_per_mtoken=getattr(caps, "cache_hit_cny_per_mtoken", 0.0),
pricing=getattr(caps, "pricing", {}) or {},
extra_units={
k: v for k, v in usage.items()
if k not in ("tokens_in", "tokens_out") and v
},
response=response,
kind="task_title",
)

225
scripts/reprice_usage.py Normal file
View File

@ -0,0 +1,225 @@
"""按版本化模型价格重算历史 usage_events默认只输出 dry-run 汇总。
示例
.venv/Scripts/python.exe scripts/reprice_usage.py --from 2026-08-16T16:00:00Z
.venv/Scripts/python.exe scripts/reprice_usage.py --from 2026-08-16T16:00:00Z \
--apply --confirm APPLY_DEEPSEEK_REPRICE
脚本不加载 .env调用方必须显式提供 ZCBOT_DB_URL生产写入前先核对 dry-run 输出和
打印的脱敏目标缺少 cache_hit_tokens 的旧辅助调用按全部未命中估算并留标记
"""
from __future__ import annotations
import argparse
import os
from collections import defaultdict
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
import sys
from typing import Any
from sqlalchemy import select
from sqlalchemy.engine import make_url
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from core.capabilities import ModelCapabilities # noqa: E402
from core.pricing import calculate_chat_cost, pricing_snapshot, resolve_chat_price # noqa: E402
from core.storage import session_scope # noqa: E402
from core.storage.models import UsageEvent # noqa: E402
DEFAULT_PROFILES = ("deepseek_v4.flash", "deepseek_v4.pro")
COST_KINDS = {"chat", "prompt_optimize", "context_fold", "task_title", "kb_ingest"}
CONFIRM_TEXT = "APPLY_DEEPSEEK_REPRICE"
PRICE_SNAPSHOT_KEYS = {
"pricing_revision",
"pricing_source",
"pricing_source_url",
"pricing_currency",
"pricing_at",
"price_tier",
"fx_to_cny",
"input_price_per_mtoken",
"cache_hit_price_per_mtoken",
"output_price_per_mtoken",
"input_cost_cny",
"cache_hit_cost_cny",
"output_cost_cny",
}
def _parse_datetime(value: str) -> datetime:
dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="重算历史模型成本(默认 dry-run")
p.add_argument("--from", dest="from_at", required=True, type=_parse_datetime)
p.add_argument("--profile", action="append", dest="profiles")
p.add_argument("--apply", action="store_true", help="事务内写回;默认只预览")
p.add_argument("--rollback", action="store_true", help="恢复本 revision 保存的旧成本")
p.add_argument("--confirm", default="", help=f"写入确认串:{CONFIRM_TEXT}")
return p.parse_args()
def _target() -> str:
raw = os.environ.get("ZCBOT_DB_URL", "").strip()
if not raw:
raise RuntimeError("ZCBOT_DB_URL is not set")
return make_url(raw).render_as_string(hide_password=True)
def main() -> int:
args = _args()
if args.apply and args.rollback:
raise SystemExit("[ERR] --apply 与 --rollback 不能同时使用")
if (args.apply or args.rollback) and args.confirm != CONFIRM_TEXT:
raise SystemExit(f"[ERR] 写入必须传 --confirm {CONFIRM_TEXT}")
profiles = tuple(args.profiles or DEFAULT_PROFILES)
caps_by_profile = {
profile: ModelCapabilities.load(profile, ROOT / "config" / "models")
for profile in profiles
}
revisions = {
period.get("revision")
for caps in caps_by_profile.values()
for period in caps.pricing.get("periods", [])
if period.get("revision")
}
target = _target()
mode = "ROLLBACK" if args.rollback else ("APPLY" if args.apply else "DRY-RUN")
print(f"[INFO] mode={mode} target={target} from={args.from_at.isoformat()}")
print(f"[INFO] profiles={','.join(profiles)} revisions={','.join(sorted(revisions))}")
summary: dict[tuple[str, str], dict[str, Any]] = defaultdict(
lambda: {
"rows": 0,
"estimated": 0,
"old": Decimal("0"),
"new": Decimal("0"),
"estimated_old": Decimal("0"),
"estimated_new": Decimal("0"),
}
)
estimated_by_kind: dict[tuple[str, str], dict[str, Any]] = defaultdict(
lambda: {"rows": 0, "old": Decimal("0"), "new": Decimal("0")}
)
changed = 0
with session_scope() as s:
events = s.execute(
select(UsageEvent).where(
UsageEvent.model_profile.in_(profiles),
UsageEvent.kind.in_(COST_KINDS),
UsageEvent.created_at >= args.from_at,
).order_by(UsageEvent.created_at, UsageEvent.event_id)
).scalars().all()
for event in events:
units = dict(event.units or {})
caps = caps_by_profile[event.model_profile]
if args.rollback:
if units.get("repricing_revision") not in revisions:
continue
previous = units.get("previous_cost_cny")
if previous is None:
continue
old = Decimal(str(event.cost_cny or 0))
new = Decimal(str(previous))
previous_pricing = units.get("previous_pricing_units") or {}
for price_key in PRICE_SNAPSHOT_KEYS:
units.pop(price_key, None)
if isinstance(previous_pricing, dict):
units.update(previous_pricing)
for key in list(units):
if (
key.startswith("repricing_")
or key in {"previous_cost_cny", "previous_pricing_units"}
):
units.pop(key, None)
key = (event.model_profile, "rollback")
else:
quote = resolve_chat_price(caps.pricing, occurred_at=event.created_at)
if quote is None or units.get("repricing_revision") == quote.revision:
continue
tokens_in = int(units.get("tokens_in") or 0)
tokens_out = int(units.get("tokens_out") or 0)
# 旧主循环一直提取 DeepSeek cache usage但 record_chat_usage 的
# extra_units 会省略值为 0 的字段,所以 chat 缺 key 可判定为零命中;
# 旧辅助调用没有提取缓存明细,缺 key 才是真正的不确定。
has_cache = "cache_hit_tokens" in units or event.kind == "chat"
cache_hit = int(units.get("cache_hit_tokens") or 0)
breakdown = calculate_chat_cost(
quote,
prompt_tokens=tokens_in,
completion_tokens=tokens_out,
cache_hit_tokens=cache_hit,
)
old = Decimal(str(event.cost_cny or 0))
new = breakdown.total_cny
previous_pricing = {
key: units[key] for key in PRICE_SNAPSHOT_KEYS if key in units
}
units.update(pricing_snapshot(quote, breakdown))
units.update({
"repricing_revision": quote.revision,
"previous_cost_cny": float(old),
"repriced_at": datetime.now(timezone.utc).isoformat(),
"repricing_time_basis": "usage_event.created_at",
"repricing_estimated": not has_cache,
"previous_pricing_units": previous_pricing,
})
key = (event.model_profile, quote.tier)
if not has_cache:
summary[key]["estimated"] += 1
summary[key]["estimated_old"] += old
summary[key]["estimated_new"] += new
uncertain = estimated_by_kind[(event.model_profile, event.kind)]
uncertain["rows"] += 1
uncertain["old"] += old
uncertain["new"] += new
bucket = summary[key]
bucket["rows"] += 1
bucket["old"] += old
bucket["new"] += new
changed += 1
if args.apply or args.rollback:
event.cost_cny = new.quantize(Decimal("0.000001"))
event.units = units
if not (args.apply or args.rollback):
s.rollback()
total_old = Decimal("0")
total_new = Decimal("0")
for (profile, tier), item in sorted(summary.items()):
total_old += item["old"]
total_new += item["new"]
print(
f"[INFO] {profile} tier={tier} rows={item['rows']} "
f"estimated={item['estimated']} old={item['old']:.6f} "
f"new={item['new']:.6f} delta={(item['new'] - item['old']):.6f} "
f"estimated_delta={(item['estimated_new'] - item['estimated_old']):.6f}"
)
for (profile, kind), item in sorted(estimated_by_kind.items()):
print(
f"[WARN] estimated profile={profile} kind={kind} rows={item['rows']} "
f"old={item['old']:.6f} new={item['new']:.6f} "
f"delta={(item['new'] - item['old']):.6f}"
)
print(
f"[OK] rows={changed} old={total_old:.6f} new={total_new:.6f} "
f"delta={(total_new - total_old):.6f} mode={mode}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -110,8 +110,11 @@ class LLMKwargsTests(unittest.TestCase):
self.assertEqual(caps.reasoning_effort_levels, ["low", "high", "max"])
self.assertEqual(caps.default_reasoning_effort, "high")
self.assertEqual(caps.max_output, 8192)
self.assertEqual(caps.output_cny_per_mtoken, 2.0)
self.assertEqual(caps.cache_hit_cny_per_mtoken, 0.02)
self.assertEqual(caps.output_cny_per_mtoken, 4.752)
self.assertEqual(caps.cache_hit_cny_per_mtoken, 0.0504)
self.assertEqual(
caps.pricing["periods"][0]["revision"], "deepseek-v4-20260816"
)
self.assertEqual(caps.thinking_transport, "extra_body")
self.assertEqual(caps.reasoning_replay, "tool_turn")

98
tests/test_pricing.py Normal file
View File

@ -0,0 +1,98 @@
from contextlib import contextmanager
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from uuid import uuid4
from core.capabilities import ModelCapabilities
from core.pricing import calculate_chat_cost, resolve_chat_price
from core.storage.usage import record_chat_usage
MODELS_DIR = Path(__file__).resolve().parents[1] / "config" / "models"
class PricingTests(unittest.TestCase):
def test_deepseek_flash_selects_peak_and_off_peak(self) -> None:
caps = ModelCapabilities.load("deepseek_v4.flash", MODELS_DIR)
# 2026-08-17 是周一01:00 UTC 为峰时起点04:00 已回到谷时。
peak = resolve_chat_price(
caps.pricing,
occurred_at=datetime(2026, 8, 17, 1, 0, tzinfo=timezone.utc),
)
off_peak = resolve_chat_price(
caps.pricing,
occurred_at=datetime(2026, 8, 17, 4, 0, tzinfo=timezone.utc),
)
self.assertIsNotNone(peak)
self.assertIsNotNone(off_peak)
assert peak is not None and off_peak is not None
self.assertEqual(peak.tier, "peak")
self.assertEqual(peak.input_per_mtoken, Decimal("0.44"))
self.assertEqual(off_peak.tier, "default")
self.assertEqual(off_peak.output_per_mtoken, Decimal("0.66"))
def test_deepseek_weekend_is_off_peak(self) -> None:
caps = ModelCapabilities.load("deepseek_v4.pro", MODELS_DIR)
quote = resolve_chat_price(
caps.pricing,
occurred_at=datetime(2026, 8, 22, 2, 0, tzinfo=timezone.utc),
)
assert quote is not None
self.assertEqual(quote.tier, "default")
def test_usd_quote_calculates_cache_split_in_cny(self) -> None:
caps = ModelCapabilities.load("deepseek_v4.flash", MODELS_DIR)
quote = resolve_chat_price(
caps.pricing,
occurred_at=datetime(2026, 8, 17, 1, 0, tzinfo=timezone.utc),
)
assert quote is not None
result = calculate_chat_cost(
quote,
prompt_tokens=1_000_000,
completion_tokens=500_000,
cache_hit_tokens=800_000,
)
# miss:0.2*0.44*7.2 + hit:0.8*0.014*7.2 + out:0.5*1.32*7.2
self.assertEqual(result.total_cny, Decimal("5.466240"))
self.assertEqual(result.cache_miss_tokens, 200_000)
def test_local_catalog_precedes_litellm_cost_map(self) -> None:
caps = ModelCapabilities.load("deepseek_v4.flash", MODELS_DIR)
added = []
class FakeSession:
def add(self, row):
added.append(row)
@contextmanager
def fake_scope():
yield FakeSession()
with (
patch("core.storage.usage.session_scope", fake_scope),
patch("core.storage.usage._safe_chat_cost_usd", return_value=Decimal("999")),
):
cost = record_chat_usage(
task_id=None,
user_id=uuid4(),
message_id=None,
model_profile="deepseek_v4.flash",
prompt_tokens=1_000_000,
completion_tokens=0,
pricing=caps.pricing,
occurred_at=datetime(2026, 8, 17, 4, 0, tzinfo=timezone.utc),
response=SimpleNamespace(created=None),
)
self.assertEqual(cost, Decimal("1.584000"))
self.assertEqual(added[0].units["pricing_revision"], "deepseek-v4-20260816")
self.assertEqual(added[0].units["price_tier"], "default")
if __name__ == "__main__":
unittest.main()

View File

@ -492,13 +492,12 @@ def register_message_routes(app, *, require_user) -> None:
回复期间也可润色下一条草稿
- image_model 影响 meta-prompt 里给 LLM 的下游 tool 提示;不动 DB
"""
from decimal import Decimal
from core.agent_builder import load_config
from core.capabilities import ModelCapabilities
from core.llm import LLM
from core.llm_transport import extract_usage_details
from core.paths import ROOT
from core.storage.models import UsageEvent
from core.storage.usage import USD_TO_CNY
from core.storage.usage import record_chat_usage
try:
tid = UUID(task_id)
@ -590,37 +589,38 @@ def register_message_routes(app, *, require_user) -> None:
if not optimized:
raise HTTPException(502, "llm returned empty optimization")
usage = getattr(response, "usage", None)
prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
try:
from litellm import completion_cost
cost_usd_raw = completion_cost(completion_response=response)
cost_usd = Decimal(str(cost_usd_raw)) if cost_usd_raw else Decimal("0")
except Exception:
cost_usd = Decimal("0")
cost_cny = (cost_usd * USD_TO_CNY).quantize(Decimal("0.000001"))
usage = extract_usage_details(getattr(response, "usage", None))
prompt_tokens = usage["tokens_in"]
completion_tokens = usage["tokens_out"]
try:
with session_scope() as s:
s.add(UsageEvent(
cost_cny = record_chat_usage(
user_id=user_id,
task_id=tid,
message_id=None,
kind="prompt_optimize",
model_profile=chosen_profile,
units={
"tokens_in": prompt_tokens,
"tokens_out": completion_tokens,
"usd_to_cny": float(USD_TO_CNY),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
cache_hit_tokens=usage["cache_hit_tokens"],
cache_hit_cny_per_mtoken=caps.cache_hit_cny_per_mtoken,
pricing=caps.pricing,
response=response,
extra_units={
"image_model_hint": img_variant or "",
"video_model_hint": vid_variant or "",
**{
k: v for k, v in usage.items()
if k not in ("tokens_in", "tokens_out") and v
},
cost_cny=cost_cny,
))
},
)
except Exception as e:
# 记账失败不阻塞返结果 — 用户拿到润色文本要紧,事后人工补
print(f"[optimize_prompt] usage record failed: {type(e).__name__}: {e}", flush=True)
cost_cny = 0
return {
"optimized": optimized,