dsh-cost-meter 1.6.6 → 1.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  Per-conversation cost · daily totals · OpenCode Go subscription quota display · budget with usage percentage · official account balance · custom provider balance · balance progress bar · history · peak/off-peak pricing hours display (peak hours UTC 01:00–04:00, 06:00–10:00; from Aug 23, 2026 weekends are billed at off-peak prices all day, shown as “Weekend — all off-peak”) · pre-switch popup & system-notification alerts for peak/off-peak changes (position / lead time / alert type configurable) · one-click price sync from the official docs · Codex-style token usage heat grid · multi-vendor model pricing (built-in 90+ model price catalog with auto-matching) · mainstream Coding Plan quota queries & display (Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet) plan/API dual-track billing (subscription quota vs pay-as-you-go money separated, per-1% & full-window token/equivalent-cost estimates with daily/weekly/monthly curves) · · quota strip above the input box (budget / Go / coding-plan usage in one row, toggleable)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.6.3-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.6.8-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
10
10
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-dsh--plugin-4176E6)](https://github.com/deepseek-ai/deepseek-harness)
@@ -231,22 +231,22 @@ Real captures from an actual DSH sidebar of the period strip and collapsed verti
231
231
  dsh plugin --profile web add dsh-cost-meter
232
232
  ```
233
233
 
234
- **PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.6.6`** — review the script before running):
234
+ **PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.6.8`** — review the script before running):
235
235
 
236
236
  ```powershell
237
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.6.6/install.ps1 | iex
237
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.6.8/install.ps1 | iex
238
238
  ```
239
239
 
240
240
  **Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
241
241
 
242
242
  ```sh
243
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.6.6
243
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.6.8
244
244
  ```
245
245
 
246
246
  Without git, use the GitHub tag archive:
247
247
 
248
248
  ```sh
249
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.6.6.tar.gz
249
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.6.8.tar.gz
250
250
  ```
251
251
 
252
252
  After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
@@ -259,11 +259,11 @@ dsh web
259
259
 
260
260
  Symptom: `dsh plugin --profile web add` fails with `[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] N lockfile entries failed verification`.
261
261
 
262
- Cause: your environment (pnpm config or a policy bundled into the invoking installer) enforces the "minimum release age" supply-chain protection — any lockfile entry **published more recently than the threshold** is rejected. Plugin releases up to v1.6.3 declared floating ranges for their runtime dependencies, so a fresh install resolved them to whatever was newest at that moment (`^0.1.0-rc.6` was observed to float onto rc.8 published barely a week earlier), which such a policy refuses.
262
+ Cause: your environment (pnpm config or a policy bundled into the invoking installer) enforces the "minimum release age" supply-chain protection — any lockfile entry **published more recently than the threshold** is rejected. Plugin releases from before runtime dependencies were exact-pinned declared floating ranges, so a fresh install resolved them to whatever was newest at that moment (`^0.1.0-rc.6` was observed to float onto rc.8 published barely a week earlier), which such a policy refuses.
263
263
 
264
264
  Fix:
265
265
 
266
- 1. **Upgrade to v1.6.6+**: all three runtime dependencies (`@deepseek-ai/dsh-credentials`, `@deepseek-ai/dsh-home-paths`, `zod`) are now exact-pinned — a pinned version's publish date never changes, so it satisfies any age threshold and this plugin can no longer trigger the error;
266
+ 1. **Upgrade to v1.6.7+**: all three runtime dependencies (`@deepseek-ai/dsh-credentials`, `@deepseek-ai/dsh-home-paths`, `zod`) are now exact-pinned — a pinned version's publish date never changes, so it satisfies any age threshold and this plugin can no longer trigger the error;
267
267
  2. If the error is triggered by **another plugin's** dependencies instead, append an exclusion for the offending `name@version` printed in the error to the profile's `pnpm-workspace.yaml` (default `$DSH_HOME/profiles/web/pnpm-workspace.yaml`) and retry:
268
268
 
269
269
  ```yaml
@@ -298,7 +298,9 @@ dsh plugin --profile web add link:./dsh-cost-meter # symlink; edit lib/client.j
298
298
  - The ledger always stores amounts in **USD**; currency and FX rate only affect display (default 1 USD = 7.2 CNY, configurable);
299
299
  - The session badge is **billed exactly** at the moment each call is made (host-exported per-call cost), just like daily/monthly/cumulative totals and the budget;
300
300
  - Billing sources are the `usage` block of every model call (including sub-agents, compression, title generation and other auxiliary calls), matching the billable view;
301
- - **Peak/off-peak tiers follow the request-initiation moment** (v1.6.6): a streaming call can span the tier boundary hour; attributing by completion time would put a request started minutes earlier into the wrong tier;
301
+ - **Peak/off-peak tiers follow the request-initiation moment**: a streaming call can span the tier boundary hour; attributing by completion time would put a request started minutes earlier into the wrong tier;
302
+ - **Peak effective-time anchoring** (v1.6.7): the official pricing page no longer lists an effective time, and price sync no longer resets the peak effective moment to "now" — historical recomputes (session projection refolds / per-model backfill) always tier events against the 2026-08-16 16:00 UTC boundary, so peak-hour history is no longer re-costed at half price; ledgers polluted earlier are clamped back automatically on upgrade (idempotent migration);
303
+ - **Switching pricing currency re-bases the whole history** (v1.6.7): after the pricing-currency setting flips and re-syncs, historical entries are re-costed on the new price table in the background (days fully covered by session logs are replaced wholesale; sessions whose logs were cleaned keep their original basis), so history and the official bill share one basis, with a notice on completion; on upgrading to this version, existing ledgers that had switched currency and ended up with mixed bases are recomputed once automatically;
302
304
  - **Aligning with official bills**: ① minute-level lag vs live official numbers is expected — the ledger flushes on a 2s debounce, and streams still in flight at shutdown are billed server-side while their usage block is never received (the gap concentrates in cache-hit columns); ② reasoning tokens are reported separately by the API and are **not billed** — the token columns sum five buckets and will never line up with the official three columns, so **reconcile by amount**; ③ with pricing currency set to CNY, entries are recorded directly on the official CNY price list (recommended for CNY-billed accounts); with USD, displayed amounts go through a fixed FX rate and carry a small structural difference (the two lists' per-column ratios are not uniform);
303
305
  - Budget and over-budget warnings **only warn — they never block calls**.
304
306
  - **Plan/API dual-track billing** (issue #64): calls on subscription-style channels (MiniMax, manually-marked Codex, etc.) are recorded as catalog-price equivalents only; budget/today-cost/overview cards count the pay-as-you-go (API) channel exclusively.
@@ -308,8 +310,10 @@ dsh plugin --profile web add link:./dsh-cost-meter # symlink; edit lib/client.j
308
310
  ## Data storage
309
311
 
310
312
  - Ledger: `$DSH_HOME/storages/cost-meter/ledger.json` (atomic write + 2-second debounce; retained per `historyDays`, up to 200 per-session entries per day);
313
+ - **API keys never touch disk** (since v1.6.8): all credentials (OpenCode Go key, coding-plan keys, Volcano AK/SK) live only in the DSH credential store — neither the ledger file nor anything sent back to the settings page contains plaintext; the settings inputs are write-only and never echo a saved key back;
311
314
  - Every settings change is **saved instantly and automatically** (600 ms debounce) — no manual save needed;
312
- - Delete the ledger file to reset everything, or use “Clear all history” in Settings.
315
+ - Delete the ledger file to reset everything, or use “Clear all history” in Settings;
316
+ - Privacy boundary: balance/quota endpoints (official balance and each coding plan) **only get contacted after you explicitly enable the corresponding provider** — no network traffic for disabled ones.
313
317
 
314
318
  ## Architecture
315
319
 
@@ -370,7 +374,7 @@ dsh --profile web --port 3099 # real startup (watch lo
370
374
  ## Known limitations
371
375
 
372
376
  - Official-page parsing depends on the current page structure; after a redesign, “Sync prices from official docs” fails — edit the price table manually as a fallback;
373
- - The session badge is estimated at the current price tier; exact figures come from the ledger;
377
+ - The session badge's fallback estimate (used when ledger data is unavailable) prices all of a session's calls at the tier of the *current* moment, including plan-type sessions: a session spanning peak and off-peak hours gets its off-peak portion overestimated during peak hours; exact figures come from the ledger (which bills each call at its own initiation moment);
374
378
  - Price sync overwrites the same-named models listed on the official page; custom model entries are unaffected;
375
379
  - Balance lookup needs network access to api.deepseek.com and a valid API key; **the API key is only ever sent to the official domain** (if baseURL points at a non-official host, balance queries refuse to run — model requests are unaffected);
376
380
  - The OpenCode Go quota endpoint is the official opencode.ai endpoint (community-documented); if its response shape changes, the Settings page shows an error and the display can be turned off in Display settings;
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  本会话费用 · 当日费用 · OpenCode Go 订阅额度显示 · 预算与已用百分比 · 官方账户余额 · 自定义 Provider 余额查询(可配任意 HTTP 端点) · 余额三段进度条 · 历史记录 · 峰谷计价时段显示(UTC 01:00–04:00、06:00–10:00 为峰时段;2026-08-23 起周末全天按谷价,显示「周末时段——全谷价」) · 峰/谷切换前弹窗与系统通知提醒(位置/提前量/提醒类型可配) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / 火山方舟 九家,含 Volcano Ark AK/SK 签名) · Plan/API 双轨计费(订阅额度与按量金额分离统计,每 1% 额度与满窗的 token/等值金额估算及日/周/月曲线) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.6.3-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.6.8-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
10
10
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-dsh--plugin-4176E6)](https://github.com/deepseek-ai/deepseek-harness)
@@ -233,22 +233,22 @@
233
233
  dsh plugin --profile web add dsh-cost-meter
234
234
  ```
235
235
 
236
- **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.6.6`**,建议先下载审阅再运行):
236
+ **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.6.8`**,建议先下载审阅再运行):
237
237
 
238
238
  ```powershell
239
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.6.6/install.ps1 | iex
239
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.6.8/install.ps1 | iex
240
240
  ```
241
241
 
242
242
  **或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
243
243
 
244
244
  ```sh
245
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.6.6
245
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.6.8
246
246
  ```
247
247
 
248
248
  没有 git 时可用 GitHub tag 打包直链:
249
249
 
250
250
  ```sh
251
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.6.6.tar.gz
251
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.6.8.tar.gz
252
252
  ```
253
253
 
254
254
  安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
@@ -261,11 +261,11 @@ dsh web
261
261
 
262
262
  症状:`dsh plugin --profile web add` 阶段安装失败,pnpm 报 `[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] N lockfile entries failed verification`。
263
263
 
264
- 原因:你的环境(pnpm 配置或上层安装器自带策略)启用了「最小发布年龄」供应链保护——lockfile 中**发布时间距今小于阈值**的包一律拒绝。插件 v1.6.3 及更早版本的生产依赖是浮动区间,首次安装会解析到当时最新发布版(实测 `^0.1.0-rc.6` 漂到仅发布一周左右的 rc.8),在该策略下即被拒绝。
264
+ 原因:你的环境(pnpm 配置或上层安装器自带策略)启用了「最小发布年龄」供应链保护——lockfile 中**发布时间距今小于阈值**的包一律拒绝。插件引入依赖精确锁版之前的历史版本,生产依赖是浮动区间,首次安装会解析到当时最新发布版(实测 `^0.1.0-rc.6` 漂到仅发布一周左右的 rc.8),在该策略下即被拒绝。
265
265
 
266
266
  处理:
267
267
 
268
- 1. **升级到 v1.6.6+**:三个运行时依赖(`@deepseek-ai/dsh-credentials`、`@deepseek-ai/dsh-home-paths`、`zod`)已全部精确锁版——锁定版本的发布时间固定不变,对任意年龄阈值永远满足,本插件不再可能触发该错误;
268
+ 1. **升级到 v1.6.7+**:三个运行时依赖(`@deepseek-ai/dsh-credentials`、`@deepseek-ai/dsh-home-paths`、`zod`)已全部精确锁版——锁定版本的发布时间固定不变,对任意年龄阈值永远满足,本插件不再可能触发该错误;
269
269
  2. 若报错由**其他插件**的依赖触发,可在 profile 目录的 `pnpm-workspace.yaml`(默认 `$DSH_HOME/profiles/web/pnpm-workspace.yaml`)按报错列出的条目追加排除后重试:
270
270
 
271
271
  ```yaml
@@ -300,7 +300,9 @@ dsh plugin --profile web add link:./dsh-cost-meter # 符号链接,改 lib/clien
300
300
  - 账本金额恒以**美元**存储,币种/汇率仅影响显示(默认 1 USD = 7.2 CNY,可改);
301
301
  - 会话徽章与当日/月度/累计、预算一样,按每次调用的**实际时刻精确计费**(宿主导出的逐次成本);
302
302
  - 计费来源为每次模型调用的 usage 块(含子代理、压缩、标题等辅助调用),与账单口径一致;
303
- - **峰谷档位按请求发起时刻判定**(v1.6.6):流式调用可能跨峰谷边界整点,以完成时刻归档会把数分钟前发起的请求算进另一个峰位;
303
+ - **峰谷档位按请求发起时刻判定**:流式调用可能跨峰谷边界整点,以完成时刻归档会把数分钟前发起的请求算进另一个峰位;
304
+ - **峰谷生效时刻锚定**(v1.6.7):官方价格页已不再标注生效时间,价格同步不再把峰谷生效时刻重置为「同步时刻」——历史重算(会话投影回放 / 按模型回填)一律按 2026-08-16 16:00 UTC 分界判档,峰时历史事件不再被按谷价半价重算;此前被污染的存量账本升级时自动钳制修复(幂等迁移);
305
+ - **币种切换即全量换基准**(v1.6.7):「价格币种」切换并同步后,历史账目按新价目在后台整体重算(会话日志覆盖完整的日子整体替换,日志已清理的会话保持原口径),历史与官方账单同基准,完成后有提示;升级到本版时,此前切换过币种而新旧口径并存的存量账本自动重算一次;
304
306
  - **官方账单对齐口径**:① 与官方实时数字存在**分钟级时差**属预期——账本 2 秒防抖落盘,关停瞬间仍在途的流会被服务端照常扣费而 usage 块无人接收(缺口集中在缓存命中列);② reasoning tokens 由 API 单独上报且**不计费**,token 列为五桶合计,与官方「三列」天然对不齐,**对账请以金额为准**;③ 「价格币种」设为人民币时按官方 CNY 价目直计入账,与人民币账单币种一致(CNY 计价账号推荐);设为 USD 时显示金额经固定汇率折算,与 CNY 直计存在结构性细差(两套价目比值非均匀);
305
307
  - 预算与超支提示**仅提醒,不阻止调用**。
306
308
  - **Plan/API 双轨计费**(issue #64):订阅制渠道(MiniMax / Codex 手动标记等)的调用金额只记「等值」,预算/今日费用/概览卡片等金额展示仅统计按量计费(API)部分;
@@ -310,8 +312,10 @@ dsh plugin --profile web add link:./dsh-cost-meter # 符号链接,改 lib/clien
310
312
  ## 数据存储
311
313
 
312
314
  - 账本:`$DSH_HOME/storages/cost-meter/ledger.json`(原子写入 + 2 秒防抖;按 `historyDays` 保留,每日最多 200 个会话明细);
315
+ - **API Key 不落盘**(v1.6.8 起):所有密钥(OpenCode Go Key、各 Coding Plan Key、火山 AK/SK)只存入 DSH 凭据库,账本文件与设置页回传均不含明文;设置页输入框为 write-only,保存后不可回显;
313
316
  - 所有设置修改**即时自动保存**(600ms 防抖),无需手动保存;
314
- - 删除账本文件即可清零,或使用设置页「清除全部历史」。
317
+ - 删除账本文件即可清零,或使用设置页「清除全部历史」;
318
+ - 隐私边界:余额/额度类端点(官方余额与各 Coding Plan)**仅在用户显式启用对应 Provider 时**才会出站请求,未启用不产生任何网络流量。
315
319
 
316
320
  ## 架构
317
321
 
@@ -374,7 +378,7 @@ dsh --profile web --port 3099 # 真机启动(观察启
374
378
 
375
379
  - 历史按模型回填依赖宿主会话日志仍在盘:日志已被清理的早期调用无法逐模型重建,只能以「未分模型」残差行计入当日合计;
376
380
  - 官方页面解析依赖当前页面结构;改版后「从官方文档同步价格」会报错,可手动编辑价格表兜底;
377
- - 会话徽章按当前价格档位估算,精确费用以账本为准;
381
+ - 会话徽章在账本数据不可用时的回退估算按「当前时刻」的价格档位给该会话全部调用定价(含 Plan 类会话):跨峰谷时段的会话在峰时会高估其谷时部分,精确费用以账本为准(账本按每次调用的发起时刻逐笔计价);
378
382
  - 价格同步会覆盖官方页面列出的同名模型价格,自定义模型条目不受影响;
379
383
  - 余额查询需要可访问 api.deepseek.com 的网络与有效 API Key;**API Key 只会发往官方域名**(baseURL 指向非官方域名时余额查询拒绝请求,模型请求不受影响);
380
384
  - OpenCode Go 额度接口为 opencode.ai 官方端点(社区文档);接口结构变化时设置页会显示错误,可在显示设置中关闭该显示;
package/lib/backfill.js CHANGED
@@ -685,6 +685,93 @@ export async function importLegacyHistory(ledger, sessionsRoot) {
685
685
  return result
686
686
  }
687
687
 
688
+ /**
689
+ * 币种切换后的历史全量换基准(v1.6.7):回放全部会话日志,把账本中「能被回放
690
+ * 完整覆盖」的会话行按当前价格表逐事件重定价,并同步调整日期聚合。
691
+ *
692
+ * 背景(用户实测:切人民币后「误差更大」):切换价格币种只换价格表(prices.models
693
+ * + prices.currency),历史存储金额保持旧表口径;而投影 refold 用当前表重算 →
694
+ * 会话徽章(新口径)与今日卡片/历史明细(旧口径)对同一天给出两套数,与官方账单
695
+ * 对比时表现为偏差扩大。本函数把存储口径统一到当前表,消除双口径。
696
+ *
697
+ * 安全边界(纯重定价,绝不改变 token 计量):
698
+ * - 仅当回放桶与账本行键集合一致且逐键五桶+calls 相等才替换金额;不一致
699
+ * (日志缺失/新旧去重差异/数据异常)的会话保持旧口径,计入 skippedSessions;
700
+ * - 账本中不存在的会话不新增(避免与实时计费重复);回放不到的日期不动
701
+ * (日志已清理的历史保留原口径,作为残差);
702
+ * - day 顶层金额按被替换会话的差额调整;apiCost 由调用方末尾统一跑
703
+ * splitLedgerApiCost 重写(与 plan/api 分类口径对齐)。
704
+ * @param ledger - 已打开的账本(config 已是目标币种价格表)。
705
+ * @param sessionsRoot - 宿主会话根目录($DSH_HOME/sessions)。
706
+ * @returns {{ scanned, recostedSessions, skippedSessions, recostedDays }}。
707
+ */
708
+ export async function recomputeLedgerPricingBasis(ledger, sessionsRoot) {
709
+ const result = { scanned: 0, recostedSessions: 0, skippedSessions: 0, recostedDays: 0 }
710
+ const bySession = new Map()
711
+ for (const path of listSessionLogs(sessionsRoot)) {
712
+ if ((result.scanned += 1) % 8 === 0) await new Promise(resolve => setImmediate(resolve))
713
+ let replayed
714
+ try {
715
+ replayed = replaySessionRecords(readSessionRecords(path), ledger.config, null)
716
+ } catch {
717
+ continue // 单文件损坏不阻断整体重算。
718
+ }
719
+ if (replayed.sessionId.length === 0) continue
720
+ const existing = bySession.get(replayed.sessionId)
721
+ if (existing === undefined) {
722
+ bySession.set(replayed.sessionId, replayed.days)
723
+ continue
724
+ }
725
+ mergeDayMaps(existing.days, replayed.days)
726
+ }
727
+ const touchedDates = new Set()
728
+ const tokenFields = ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning', 'calls']
729
+ for (const [sessionId, days] of bySession) {
730
+ for (const [date, pm] of Object.entries(days)) {
731
+ const day = ledger.days?.[date]
732
+ const row = day === undefined ? undefined : (Array.isArray(day.sessions) ? day.sessions : []).find(s => s?.id === sessionId)
733
+ if (row === undefined) continue // 账本无此会话行:不新增(与实时计费互斥)。
734
+ const oldPm = row.byProviderModel !== null && typeof row.byProviderModel === 'object' ? row.byProviderModel : {}
735
+ const newKeys = Object.keys(pm)
736
+ const dayPm = day.byProviderModel !== null && typeof day.byProviderModel === 'object' ? day.byProviderModel : {}
737
+ // 前置校验(全部通过才动账):键集合一致、逐键 token/calls 相等、日期聚合
738
+ // 含全部键。任何一条不满足都保持旧口径,杜绝半改状态。
739
+ let replaceable = newKeys.length > 0
740
+ for (const key of newKeys) {
741
+ const oldBucket = oldPm[key]
742
+ const dayBucket = dayPm[key]
743
+ const newBucket = pm[key]
744
+ if (oldBucket === undefined || dayBucket === undefined) { replaceable = false; break }
745
+ for (const field of tokenFields) {
746
+ if ((Number(oldBucket[field]) || 0) !== (Number(newBucket[field]) || 0)) { replaceable = false; break }
747
+ }
748
+ if (!replaceable) break
749
+ }
750
+ if (!replaceable) {
751
+ result.skippedSessions += 1
752
+ continue
753
+ }
754
+ const totals = sumDayMap(pm)
755
+ const oldCost = Number(row.cost) || 0
756
+ const oldApi = Number(row.apiCost) || 0
757
+ row.byProviderModel = cloneDayMap(pm)
758
+ row.cost = totals.cost
759
+ row.apiCost = totals.apiCost
760
+ day.cost = (Number(day.cost) || 0) - oldCost + totals.cost
761
+ day.apiCost = (Number(day.apiCost) || 0) - oldApi + totals.apiCost
762
+ for (const key of newKeys) {
763
+ const dayBucket = dayPm[key]
764
+ dayBucket.cost = (Number(dayBucket.cost) || 0) - (Number(oldPm[key].cost) || 0) + (Number(pm[key].cost) || 0)
765
+ dayBucket.apiCost = (Number(dayBucket.apiCost) || 0) - (Number(oldPm[key].apiCost) || 0) + (Number(pm[key].apiCost) || 0)
766
+ }
767
+ result.recostedSessions += 1
768
+ touchedDates.add(date)
769
+ }
770
+ }
771
+ result.recostedDays = touchedDates.size
772
+ return result
773
+ }
774
+
688
775
  /** 从目标桶(可变)中减去源桶,逐字段 clamp ≥ 0(清洗不产生负数)。 */
689
776
  function subtractBucketInto(target, source) {
690
777
  target.input = Math.max(0, (target.input ?? 0) - (source.input ?? 0))