diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b5a24..f5e8a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- 国内模型成本统计跟随最新公开价格更新,DeepSeek 会按实际峰谷时段和缓存命中量计算,历史调价后的记录也可审计矫正。 + - GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。 - 删除已发布文件后会释放个人存储配额;管理员可分别查看用户当前文件、隐藏回收站及物理总占用。 diff --git a/DESIGN.md b/DESIGN.md index ab169fe..9b24cc8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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` diff --git a/PROGRESS.md b/PROGRESS.md index 86f38a3..30920a9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 变化。 diff --git a/RUN.md b/RUN.md index 7add795..b894525 100644 --- a/RUN.md +++ b/RUN.md @@ -1021,6 +1021,8 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_" /opt | `.pptx` 预览首次慢几秒 | 正常 —— soffice 冷启 + 转换 ~2-4s,转完缓存到源同目录 `.preview/..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/.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 ` 后 `/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` 调大 | diff --git a/config/models/deepseek_v4.yaml b/config/models/deepseek_v4.yaml index 7e0f119..5b95829 100644 --- a/config/models/deepseek_v4.yaml +++ b/config/models/deepseek_v4.yaml @@ -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 diff --git a/config/models/doubao.yaml b/config/models/doubao.yaml index 7d66093..b49d57f 100644 --- a/config/models/doubao.yaml +++ b/config/models/doubao.yaml @@ -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 diff --git a/config/models/glm.yaml b/config/models/glm.yaml index aa9f158..8bb62ce 100644 --- a/config/models/glm.yaml +++ b/config/models/glm.yaml @@ -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 活跃后删除。 diff --git a/core/capabilities.py b/core/capabilities.py index ea9aa0b..cab6937 100644 --- a/core/capabilities.py +++ b/core/capabilities.py @@ -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 = "" diff --git a/core/context_fold.py b/core/context_fold.py index 2479e30..6b5b75a 100644 --- a/core/context_fold.py +++ b/core/context_fold.py @@ -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 字段的数据源,免加计数列) diff --git a/core/kb_ingest.py b/core/kb_ingest.py index 40da6b9..c956c3d 100644 --- a/core/kb_ingest.py +++ b/core/kb_ingest.py @@ -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 diff --git a/core/loop.py b/core/loop.py index 9c509d8..7ec3656 100644 --- a/core/loop.py +++ b/core/loop.py @@ -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 diff --git a/core/pricing.py b/core/pricing.py new file mode 100644 index 0000000..237c73b --- /dev/null +++ b/core/pricing.py @@ -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), + } diff --git a/core/storage/usage.py b/core/storage/usage.py index b360bfd..4b8172f 100644 --- a/core/storage/usage.py +++ b/core/storage/usage.py @@ -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 显示。 """ - 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): + # 直连 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 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( diff --git a/core/task_title.py b/core/task_title.py index 63a7780..01213af 100644 --- a/core/task_title.py +++ b/core/task_title.py @@ -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", ) diff --git a/scripts/reprice_usage.py b/scripts/reprice_usage.py new file mode 100644 index 0000000..b5abca3 --- /dev/null +++ b/scripts/reprice_usage.py @@ -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()) diff --git a/tests/test_llm_kwargs.py b/tests/test_llm_kwargs.py index 80d136a..d9b4f94 100644 --- a/tests/test_llm_kwargs.py +++ b/tests/test_llm_kwargs.py @@ -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") diff --git a/tests/test_pricing.py b/tests/test_pricing.py new file mode 100644 index 0000000..2ebd85c --- /dev/null +++ b/tests/test_pricing.py @@ -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() diff --git a/web/routers/messages.py b/web/routers/messages.py index 3c19034..9febbb0 100644 --- a/web/routers/messages.py +++ b/web/routers/messages.py @@ -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( - 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), - "image_model_hint": img_variant or "", - "video_model_hint": vid_variant or "", + cost_cny = record_chat_usage( + user_id=user_id, + task_id=tid, + message_id=None, + kind="prompt_optimize", + model_profile=chosen_profile, + 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,