fix(pricing): correct domestic model cost accounting
This commit is contained in:
parent
7ffa9eb645
commit
08754be1a4
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- 国内模型成本统计跟随最新公开价格更新,DeepSeek 会按实际峰谷时段和缓存命中量计算,历史调价后的记录也可审计矫正。
|
||||
|
||||
- GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。
|
||||
|
||||
- 删除已发布文件后会释放个人存储配额;管理员可分别查看用户当前文件、隐藏回收站及物理总占用。
|
||||
|
|
|
|||
|
|
@ -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 R2(revision 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-max;2026-08-16 起按工作日 UTC 峰谷计费,具体单价以官方价格页与版本化模型档案为准
|
||||
- 旧 `deepseek-chat/reasoner` 已于 2026-07 下线,全库仅存 `deepseek-v4-flash/pro`
|
||||
|
|
|
|||
|
|
@ -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
2
RUN.md
|
|
@ -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` 调大 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 活跃后删除。
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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 字段的数据源,免加计数列)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue