dsh-cost-meter 1.7.33 → 1.7.34

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.md CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  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 / Volcano Ark / Qwen / Xiaomi MiMo) 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)
10
10
 
11
- [![version](https://img.shields.io/badge/version-1.7.33-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
11
+ [![version](https://img.shields.io/badge/version-1.7.34-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
12
12
 
13
- **v1.7.33** adds Xiaomi MiMo Token Plan quota support and a JSON request-body editor for custom provider POST balance queries, with format validation, per-entry persistence and support for clearing the body. See the [release notes](docs/release-notes/v1.7.33.md).
13
+ **v1.7.34** fixes concurrent ledger writes from web/headless processes, preserving session details, daily totals and plan usage. Update the plugin in every profile that uses it and restart all DSH processes. See the [release notes](docs/release-notes/v1.7.34.md).
14
14
 
15
15
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
16
16
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
@@ -316,22 +316,22 @@ On Node.js 20, use `npm install -g pnpm@10` instead. See [pnpm installation and
316
316
  dsh plugin --profile web add dsh-cost-meter
317
317
  ```
318
318
 
319
- **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.7.33`** — review the script before running):
319
+ **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.7.34`** — review the script before running):
320
320
 
321
321
  ```powershell
322
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.33/install.ps1 | iex
322
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.34/install.ps1 | iex
323
323
  ```
324
324
 
325
325
  **Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
326
326
 
327
327
  ```sh
328
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.33
328
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.34
329
329
  ```
330
330
 
331
331
  Without git, use the GitHub tag archive:
332
332
 
333
333
  ```sh
334
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.33.tar.gz
334
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.34.tar.gz
335
335
  ```
336
336
 
337
337
  After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
package/README.zh-CN.md CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  本会话费用 · 当日费用 · 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 / 火山方舟 / 千问 / 小米 MiMo 十一家,含 Volcano Ark AK/SK 签名与 MiMo 控制台 Cookie 查询) · Plan/API 双轨计费(订阅额度与按量金额分离统计,每 1% 额度与满窗的 token/等值金额估算及日/周/月曲线) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
10
10
 
11
- [![version](https://img.shields.io/badge/version-1.7.33-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
11
+ [![version](https://img.shields.io/badge/version-1.7.34-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
12
12
 
13
- **v1.7.33**:新增小米 MiMo Token Plan 额度查询;自定义 Provider 余额新增 POST JSON 请求体编辑,支持格式校验、每条配置独立保存和清空请求体。详见[更新说明](docs/release-notes/v1.7.33.md)。
13
+ **v1.7.34**:修复 web/headless 等多进程写账本互相覆盖,保留会话明细、每日合计和套餐用量。请更新所有使用此插件的 profile,并重启全部 DSH 进程。详见[更新说明](docs/release-notes/v1.7.34.md)。
14
14
 
15
15
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
16
16
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
@@ -316,22 +316,22 @@ Node.js 20 请改用 `npm install -g pnpm@10`。版本要求见 [pnpm 官方安
316
316
  dsh plugin --profile web add dsh-cost-meter
317
317
  ```
318
318
 
319
- **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.33`**,建议先下载审阅再运行):
319
+ **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.34`**,建议先下载审阅再运行):
320
320
 
321
321
  ```powershell
322
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.33/install.ps1 | iex
322
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.34/install.ps1 | iex
323
323
  ```
324
324
 
325
325
  **或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
326
326
 
327
327
  ```sh
328
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.33
328
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.34
329
329
  ```
330
330
 
331
331
  没有 git 时可用 GitHub tag 打包直链:
332
332
 
333
333
  ```sh
334
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.33.tar.gz
334
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.34.tar.gz
335
335
  ```
336
336
 
337
337
  安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
package/lib/index.js CHANGED
@@ -2231,11 +2231,7 @@ function createService(ctx, ledger, openRouterPrices) {
2231
2231
  // 清空全部历史:除 days 外,Plan 百分比采样/小时桶与余额对账基准一并
2232
2232
  // 重置,否则残留样本仍会推算出 planStats、对账基准继续引用旧日合计,
2233
2233
  // 与「清空全部历史」语义不一致。
2234
- ledger.days = {}
2235
- ledger.planSamples = {}
2236
- ledger.planHourBuckets = {}
2237
- ledger.balanceRef = null
2238
- ledger.scheduleWrite()
2234
+ ledger.resetHistory()
2239
2235
  return build(false)
2240
2236
  },
2241
2237
 
@@ -0,0 +1,223 @@
1
+ /** Cross-process ledger transactions. Runtime secrets never enter these snapshots. */
2
+ import { mkdirSync, readdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, rmdirSync, statSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { isDeepStrictEqual as equal } from 'node:util'
6
+ import { appendHourBucket, pruneHourBuckets, PLAN_SAMPLE_CAP, PLAN_SAMPLE_MAX_AGE_MS } from './plan-billing.js'
7
+
8
+ const fields = ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning', 'calls', 'cost', 'apiCost']
9
+ const safe = key => !['__proto__', 'constructor', 'prototype'].includes(key)
10
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value)
11
+ const clone = value => value === undefined ? undefined : structuredClone(value)
12
+ const pause = new Int32Array(new SharedArrayBuffer(4))
13
+
14
+ /** mkdir is exclusive on Windows and POSIX. A live owner is never evicted by age. */
15
+ export function withLedgerLock(path, action, timeoutMs = 5000) {
16
+ mkdirSync(dirname(path), { recursive: true })
17
+ const lock = `${path}.lock`
18
+ const owner = `${process.pid}-${randomUUID()}`
19
+ const deadline = Date.now() + timeoutMs
20
+ for (;;) {
21
+ try {
22
+ mkdirSync(lock)
23
+ try { writeFileSync(join(lock, owner), '') } catch (error) { rmdirSync(lock); throw error }
24
+ break
25
+ } catch (error) {
26
+ // Windows can briefly report EPERM while another process finishes removing
27
+ // a directory whose last read handle has only just closed.
28
+ if (!['EEXIST', 'EPERM'].includes(error.code)) throw error
29
+ try {
30
+ const names = readdirSync(lock)
31
+ const previous = names.length === 1 && names[0].match(/^(\d+)-[a-f0-9-]+$/)
32
+ if (previous) {
33
+ let dead = false
34
+ try { process.kill(Number(previous[1]), 0) } catch (probe) { dead = probe.code === 'ESRCH' }
35
+ // Only the contender that removes this unique owner file may remove the
36
+ // directory. Other contenders cannot accidentally unlink a new lock.
37
+ if (dead) { unlinkSync(join(lock, names[0])); rmdirSync(lock); continue }
38
+ } else if (names.length === 0 && Date.now() - statSync(lock).mtimeMs > 30_000) {
39
+ rmdirSync(lock) // Also recovers a process killed between mkdir and owner creation.
40
+ continue
41
+ }
42
+ } catch (probe) {
43
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM'].includes(probe.code)) throw probe
44
+ }
45
+ if (Date.now() >= deadline) throw new Error('Ledger is busy; preserving pending changes for retry')
46
+ Atomics.wait(pause, 0, 0, 10)
47
+ }
48
+ }
49
+ try { return action() } finally {
50
+ unlinkSync(join(lock, owner))
51
+ rmdirSync(lock)
52
+ }
53
+ }
54
+
55
+ export function readLedger(path) {
56
+ let text
57
+ try { text = readFileSync(path, 'utf8') } catch (error) {
58
+ if (error.code === 'ENOENT') return null
59
+ throw error
60
+ }
61
+ const value = JSON.parse(text)
62
+ if (!object(value) || value.version !== 1) throw new Error('Unsupported ledger format')
63
+ return value
64
+ }
65
+
66
+ export function writeLedger(path, state) {
67
+ const temp = `${path}.${process.pid}-${randomUUID()}.tmp`
68
+ try {
69
+ writeFileSync(temp, JSON.stringify(state), { encoding: 'utf8', flag: 'wx' })
70
+ renameSync(temp, path)
71
+ } finally {
72
+ try { unlinkSync(temp) } catch (error) { if (error.code !== 'ENOENT') throw error }
73
+ }
74
+ }
75
+
76
+ const empty = () => Object.fromEntries(fields.map(key => [key, 0]))
77
+ function add(target, values) {
78
+ for (const key of fields) target[key] = (Number(target[key]) || 0) + values[key]
79
+ }
80
+
81
+ /** Replay a priced call, not a snapshot: two equal calls are still two calls. */
82
+ export function applyAccount(state, entry) {
83
+ const { date, sessionId, atMs, providerKey, values, planId } = entry
84
+ if (!object(state.days[date])) state.days[date] = { date, ...empty(), byProviderModel: {}, sessions: [] }
85
+ const day = state.days[date]
86
+ if (!Array.isArray(day.sessions)) day.sessions = []
87
+ const containers = [day]
88
+ if (typeof sessionId === 'string' && sessionId.length > 0) {
89
+ let session = day.sessions.find(s => s.id === sessionId)
90
+ if (!session) {
91
+ session = { id: sessionId, at: atMs, ...empty(), byProviderModel: {} }
92
+ day.sessions.push(session)
93
+ if (day.sessions.length > 200) day.sessions = day.sessions.slice(-200)
94
+ }
95
+ containers.push(session)
96
+ }
97
+ for (const container of containers) {
98
+ add(container, values)
99
+ container.byProviderModel ??= {}
100
+ const bucket = container.byProviderModel[providerKey] ??= empty()
101
+ add(bucket, values)
102
+ }
103
+ if (planId !== null) state.planHourBuckets = appendHourBucket(state.planHourBuckets, planId, atMs,
104
+ values.input + values.output + values.cacheRead + values.cacheWrite + values.reasoning, values.cost)
105
+ }
106
+
107
+ /** Three-way merge for settings and maintenance; unchanged local fields retain disk values. */
108
+ function merge(base, local, disk, numeric = false) {
109
+ if (equal(local, base)) return clone(disk)
110
+ if (equal(disk, base) || equal(local, disk)) return clone(local)
111
+ if (object(local) && object(disk)) {
112
+ const out = {}
113
+ for (const key of new Set([...Object.keys(base ?? {}), ...Object.keys(local), ...Object.keys(disk)])) {
114
+ if (!safe(key)) continue
115
+ const value = merge(base?.[key], local[key], disk[key], numeric && (object(local[key]) || fields.includes(key) || key === 'tokens'))
116
+ if (value !== undefined) out[key] = value
117
+ }
118
+ return out
119
+ }
120
+ if (numeric && typeof local === 'number' && typeof disk === 'number') {
121
+ return Math.max(0, disk + local - (Number(base) || 0))
122
+ }
123
+ return clone(local)
124
+ }
125
+
126
+ const sessionMap = day => Object.fromEntries((day?.sessions ?? []).filter(s => safe(s.id)).map(s => [s.id, s]))
127
+ function mergeDays(base, local, disk) {
128
+ const out = {}
129
+ for (const date of new Set([...Object.keys(local), ...Object.keys(disk)])) {
130
+ if (!safe(date)) continue
131
+ const b = base[date], l = local[date], d = disk[date]
132
+ if (equal(b, l)) { if (d !== undefined) out[date] = clone(d); continue }
133
+ if (!l || !d) { if (l) out[date] = clone(l); continue }
134
+ if (equal(l, d)) { out[date] = clone(d); continue }
135
+ const merged = merge(b, l, d, true)
136
+ const bs = sessionMap(b), ls = sessionMap(l), ds = sessionMap(d)
137
+ const sessions = merge(bs, ls, ds, true)
138
+ // Rebase aggregate deltas instead of rebuilding totals from the retained
139
+ // sessions. Identical numbers can describe different imported sessions.
140
+ const totals = (base, local, disk) => Object.fromEntries(fields.map(key => [key,
141
+ (disk?.[key] ?? 0) + (local?.[key] ?? 0) - (base?.[key] ?? 0)]))
142
+ Object.assign(merged, totals(b, l, d))
143
+ merged.byProviderModel = {}
144
+ for (const key of new Set([...Object.keys(b?.byProviderModel ?? {}), ...Object.keys(l.byProviderModel ?? {}), ...Object.keys(d.byProviderModel ?? {})])) {
145
+ if (safe(key)) merged.byProviderModel[key] = totals(b?.byProviderModel?.[key], l.byProviderModel?.[key], d.byProviderModel?.[key])
146
+ }
147
+ // Subtract only changes already represented in the merged session. This
148
+ // deduplicates overlapping imports/repairs while retaining anonymous usage
149
+ // and totals belonging to details pruned by the 200-session limit.
150
+ for (const id of Object.keys(ls)) {
151
+ if (!ds[id] || equal(bs[id], ls[id]) || equal(bs[id], ds[id])) continue
152
+ for (const key of fields) merged[key] -= (ls[id][key] ?? 0) - (bs[id]?.[key] ?? 0) - ((sessions[id]?.[key] ?? 0) - (ds[id][key] ?? 0))
153
+ for (const [key, bucket] of Object.entries(ls[id].byProviderModel ?? {})) {
154
+ if (!safe(key) || !merged.byProviderModel[key]) continue
155
+ for (const field of fields) merged.byProviderModel[key][field] -= (bucket[field] ?? 0) - (bs[id]?.byProviderModel?.[key]?.[field] ?? 0)
156
+ - ((sessions[id]?.byProviderModel?.[key]?.[field] ?? 0) - (ds[id].byProviderModel?.[key]?.[field] ?? 0))
157
+ }
158
+ }
159
+ for (const container of [merged, ...Object.values(merged.byProviderModel)]) {
160
+ for (const key of fields) container[key] = Math.max(0, container[key])
161
+ }
162
+ merged.sessions = Object.values(sessions).sort((a, b) => (a.at ?? 0) - (b.at ?? 0)).slice(-200)
163
+ out[date] = merged
164
+ }
165
+ return out
166
+ }
167
+
168
+ function mergeSamples(base, local, disk, now) {
169
+ const out = {}
170
+ for (const provider of new Set([...Object.keys(local), ...Object.keys(disk)])) {
171
+ if (!safe(provider)) continue
172
+ out[provider] = {}
173
+ for (const window of new Set([...Object.keys(local[provider] ?? {}), ...Object.keys(disk[provider] ?? {})])) {
174
+ if (!safe(window)) continue
175
+ const byTime = list => Object.fromEntries((Array.isArray(list) ? list : []).filter(s => s && Number.isFinite(s.t)).map(s => [s.t, s]))
176
+ const samples = merge(byTime(base[provider]?.[window]), byTime(local[provider]?.[window]), byTime(disk[provider]?.[window]))
177
+ out[provider][window] = Object.values(samples).filter(s => s.t >= now - PLAN_SAMPLE_MAX_AGE_MS)
178
+ .sort((a, b) => a.t - b.t).slice(-PLAN_SAMPLE_CAP)
179
+ }
180
+ }
181
+ return out
182
+ }
183
+
184
+ // Replay pending calls into both the baseline and current disk snapshot. The
185
+ // remaining local-versus-baseline diff contains only edits/imports/repairs, so
186
+ // repeated flushes cannot add an already committed call or hourly bucket twice.
187
+ export function mergeLedger(base, local, disk, pending, now = Date.now()) {
188
+ const b = clone(base), l = clone(local), d = clone(disk ?? base)
189
+ const localReset = !equal(l.historyReset, b.historyReset)
190
+ const remoteReset = !equal(d.historyReset, b.historyReset)
191
+ const reset = localReset ? l.historyReset : d.historyReset
192
+ if (localReset || remoteReset) {
193
+ for (const state of [b, ...(localReset ? [d] : [l])]) {
194
+ state.days = {}; state.planSamples = {}; state.planHourBuckets = {}; state.balanceRef = null
195
+ }
196
+ }
197
+ for (const entry of pending) {
198
+ if (remoteReset && !localReset && entry.recordedAt <= (reset?.at ?? 0)) continue
199
+ applyAccount(b, entry)
200
+ applyAccount(d, entry)
201
+ if (remoteReset && !localReset) applyAccount(l, entry)
202
+ }
203
+ const out = {
204
+ version: 1,
205
+ config: merge(b.config, l.config, d.config),
206
+ days: mergeDays(b.days, l.days, d.days),
207
+ balanceRef: equal(b.balanceRef, l.balanceRef) ? d.balanceRef
208
+ : (d.balanceRef?.at ?? 0) > (l.balanceRef?.at ?? 0) ? d.balanceRef : l.balanceRef,
209
+ migrations: [...new Set([...(d.migrations ?? []), ...(l.migrations ?? [])])],
210
+ planSamples: mergeSamples(b.planSamples, l.planSamples, d.planSamples, now),
211
+ planHourBuckets: pruneHourBuckets(merge(b.planHourBuckets, l.planHourBuckets, d.planHourBuckets, true), now),
212
+ openrouterPriceHashes: merge(b.openrouterPriceHashes, l.openrouterPriceHashes, d.openrouterPriceHashes),
213
+ historyReset: reset ?? null,
214
+ }
215
+ const dates = Object.keys(out.days).sort()
216
+ const keep = Math.max(7, Math.min(3650, Number(out.config.historyDays) || 180))
217
+ while (dates.length > keep) delete out.days[dates.shift()]
218
+ return out
219
+ }
220
+
221
+ export function mergeRuntimeConfig(base, local, persisted) {
222
+ return merge(base, local, persisted)
223
+ }
package/lib/store.js CHANGED
@@ -5,8 +5,10 @@
5
5
  * 原子重命名,并做防抖;账本按 config.historyDays 保留最近 N 天。
6
6
  */
7
7
 
8
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
9
- import { dirname, join } from 'node:path'
8
+ import { readFileSync, renameSync } from 'node:fs'
9
+ import { randomUUID } from 'node:crypto'
10
+ import { withLedgerLock, readLedger, writeLedger, applyAccount, mergeLedger, mergeRuntimeConfig } from './ledger-persistence.js'
11
+ import { join } from 'node:path'
10
12
  import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
11
13
  import {
12
14
  DEFAULT_PEAK_EFFECTIVE_AT,
@@ -24,10 +26,9 @@ import {
24
26
  isLocalOriginProviderOrModel,
25
27
  } from './pricing.js'
26
28
  import { CODING_PLAN_PROVIDER_IDS, normalizeMiniMaxBaseUrl } from './coding-plans.js'
27
- import { DEFAULT_PLAN_PROVIDER_CLASS, PLAN_PROVIDER_IDS, billingClassOf, enabledPlanSetOf, planProviderIdOf, appendHourBucket, pruneHourBuckets, convertRecentCallsToBuckets, isRoutedThirdPartyCall } from './plan-billing.js'
29
+ import { DEFAULT_PLAN_PROVIDER_CLASS, PLAN_PROVIDER_IDS, billingClassOf, enabledPlanSetOf, planProviderIdOf, convertRecentCallsToBuckets, isRoutedThirdPartyCall } from './plan-billing.js'
28
30
 
29
31
  const LEDGER_VERSION = 1
30
- const MAX_SESSIONS_PER_DAY = 200
31
32
  const DEFAULT_HISTORY_DAYS = 180
32
33
  const GATEWAY_PROVIDER_IDS = ['antigravity', 'claude', 'codex', 'kimi', 'xai', 'workbuddy']
33
34
  const GATEWAY_DISPLAY_VALUES = ['sidebar', 'settings', 'both', 'off']
@@ -634,10 +635,6 @@ export function dedupeWrapperProviderDays(days) {
634
635
  return result
635
636
  }
636
637
 
637
- function zeroSession(id) {
638
- return { id, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0, apiCost: 0, byProviderModel: {} }
639
- }
640
-
641
638
  /** 账本数值清洗:非有限/负数(含历史版本写入的 null)一律归 0,防止污染聚合并击穿 Typert strict codec。 */
642
639
  function sanitizeNum(value) {
643
640
  const n = Number(value)
@@ -1934,7 +1931,9 @@ export class Ledger {
1934
1931
  this.closed = false
1935
1932
  this.pendingWrite = false
1936
1933
  // 余额差对账参考点({ date, total, granted, topped, at }),由 open() 载入、flush() 落盘。
1937
- this.balanceRef = null
1934
+ this.balanceRef = extras.balanceRef ?? null
1935
+ this.historyReset = extras.historyReset ?? null
1936
+ this.pendingAccounts = []
1938
1937
  this.migrations = Array.isArray(migrations) ? migrations.filter(m => typeof m === 'string') : []
1939
1938
  // 存量密钥迁移状态(v1.6.8,**不落盘**):{ ran, pending }。由 runSecretMigration 写入、
1940
1939
  // buildState 读取,用于在 UI 提示未能自动导入凭据库的密钥。
@@ -1945,12 +1944,17 @@ export class Ledger {
1945
1944
  // 自动目录上次写入的价格指纹:重启后仍能识别用户手改价格,不下发到客户端。
1946
1945
  this.openrouterPriceHashes = Object.fromEntries(Object.entries(extras.openrouterPriceHashes ?? {})
1947
1946
  .filter(([key, value]) => !['__proto__', 'constructor', 'prototype'].includes(key) && typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)))
1947
+ this.writeBase = this.snapshot()
1948
1948
  }
1949
1949
 
1950
1950
  /** 在 $DSH_HOME 下创建/加载账本。 */
1951
1951
  static open() {
1952
1952
  const root = join(resolveDshHome(), 'storages', 'cost-meter')
1953
1953
  const path = join(root, 'ledger.json')
1954
+ return withLedgerLock(path, () => Ledger.load(path))
1955
+ }
1956
+
1957
+ static load(path) {
1954
1958
  let config = defaultConfig()
1955
1959
  let days = {}
1956
1960
  let balanceRef = null
@@ -1958,6 +1962,7 @@ export class Ledger {
1958
1962
  let planSamples = {}
1959
1963
  let planHourBuckets = {}
1960
1964
  let openrouterPriceHashes = {}
1965
+ let historyReset = null
1961
1966
  try {
1962
1967
  const parsed = JSON.parse(readFileSync(path, 'utf8'))
1963
1968
  if (parsed !== null && typeof parsed === 'object') {
@@ -1966,6 +1971,7 @@ export class Ledger {
1966
1971
  try { renameSync(path, `${path}.corrupt-${Date.now()}`) } catch {}
1967
1972
  console.warn(`[dsh-cost-meter] 账本版本 ${String(parsed.version)} 不受支持,按空账本启动`)
1968
1973
  } else {
1974
+ historyReset = parsed.historyReset ?? null
1969
1975
  const cfg = typeof parsed.config === 'object' && parsed.config !== null ? parsed.config : {}
1970
1976
  // 一次性迁移:先于清洗对原始配置执行,已应用过的(账本 migrations 标记)跳过。
1971
1977
  migrations = Array.isArray(parsed.migrations) ? parsed.migrations.filter(m => typeof m === 'string') : []
@@ -2009,8 +2015,7 @@ export class Ledger {
2009
2015
  console.warn(`[dsh-cost-meter] 账本读取失败,按空账本启动: ${String(error?.message ?? error)}`)
2010
2016
  }
2011
2017
  }
2012
- const ledger = new Ledger(config, days, path, migrations, { planSamples, planHourBuckets, openrouterPriceHashes })
2013
- ledger.balanceRef = balanceRef
2018
+ const ledger = new Ledger(config, days, path, migrations, { planSamples, planHourBuckets, openrouterPriceHashes, balanceRef, historyReset })
2014
2019
  return ledger
2015
2020
  }
2016
2021
 
@@ -2058,74 +2063,15 @@ export class Ledger {
2058
2063
  cacheWrite: num(tokens?.cacheWrite),
2059
2064
  reasoning: num(tokens?.reasoning),
2060
2065
  }
2061
- const date = localDayKey(atMs)
2062
- let day = this.days[date]
2063
- if (day === undefined || day === null || typeof day !== 'object') {
2064
- day = zeroDay(date)
2065
- this.days[date] = day
2066
- }
2067
- day.input += buckets.input
2068
- day.output += buckets.output
2069
- day.cacheRead += buckets.cacheRead
2070
- day.cacheWrite += buckets.cacheWrite
2071
- day.reasoning += buckets.reasoning
2072
- day.calls += 1
2073
- day.cost += cost
2074
- day.apiCost = (day.apiCost ?? 0) + apiCost
2075
- const providerKey = `${typeof provider === 'string' && provider.length > 0 ? provider : 'deepseek'}:${String(modelId ?? 'default')}`
2076
- day.byProviderModel = day.byProviderModel ?? {}
2077
- const dayProvider = day.byProviderModel[providerKey] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0, apiCost: 0 }
2078
- day.byProviderModel[providerKey] = {
2079
- input: dayProvider.input + buckets.input,
2080
- output: dayProvider.output + buckets.output,
2081
- cacheRead: dayProvider.cacheRead + buckets.cacheRead,
2082
- cacheWrite: dayProvider.cacheWrite + buckets.cacheWrite,
2083
- reasoning: dayProvider.reasoning + buckets.reasoning,
2084
- calls: dayProvider.calls + 1,
2085
- cost: dayProvider.cost + cost,
2086
- apiCost: (dayProvider.apiCost ?? 0) + apiCost,
2087
- }
2088
- if (typeof sessionId === 'string' && sessionId.length > 0) {
2089
- let sessions = Array.isArray(day.sessions) ? day.sessions : []
2090
- let session = sessions.find(s => s.id === sessionId)
2091
- if (session === undefined) {
2092
- session = zeroSession(sessionId)
2093
- session.at = atMs // 会话首次入账时刻(按会话排行「按时间排序」用)。
2094
- sessions.push(session)
2095
- if (sessions.length > MAX_SESSIONS_PER_DAY) sessions = sessions.slice(-MAX_SESSIONS_PER_DAY)
2096
- day.sessions = sessions
2097
- }
2098
- session.input += buckets.input
2099
- session.output += buckets.output
2100
- session.cacheRead += buckets.cacheRead
2101
- session.cacheWrite += buckets.cacheWrite
2102
- session.reasoning += buckets.reasoning
2103
- session.calls += 1
2104
- session.cost += cost
2105
- session.apiCost = (session.apiCost ?? 0) + apiCost
2106
- session.byProviderModel = session.byProviderModel ?? {}
2107
- const sessionProvider = session.byProviderModel[providerKey] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0, apiCost: 0 }
2108
- session.byProviderModel[providerKey] = {
2109
- input: sessionProvider.input + buckets.input,
2110
- output: sessionProvider.output + buckets.output,
2111
- cacheRead: sessionProvider.cacheRead + buckets.cacheRead,
2112
- cacheWrite: sessionProvider.cacheWrite + buckets.cacheWrite,
2113
- reasoning: sessionProvider.reasoning + buckets.reasoning,
2114
- calls: sessionProvider.calls + 1,
2115
- cost: sessionProvider.cost + cost,
2116
- apiCost: (sessionProvider.apiCost ?? 0) + apiCost,
2117
- }
2118
- }
2119
- if (cls === 'plan') {
2120
- // 路由第三方调用(空/'deepseek' 前缀命中第三方目录)与直连 Plan 同样入小时桶,
2121
- // 与 aggregateUsageSince 整天段口径一致,否则今日段本地量漏算。
2122
- const planId = planProviderIdOf(provider)
2123
- ?? (isRoutedThirdPartyCall(provider, modelId, this.config.prices) ? 'go' : null)
2124
- if (planId !== null) {
2125
- this.planHourBuckets = appendHourBucket(this.planHourBuckets, planId, atMs,
2126
- buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite + buckets.reasoning, cost)
2127
- }
2066
+ const planId = cls === 'plan' ? (planProviderIdOf(provider)
2067
+ ?? (isRoutedThirdPartyCall(provider, modelId, this.config.prices) ? 'go' : null)) : null
2068
+ const call = {
2069
+ date: localDayKey(atMs), sessionId, atMs, recordedAt: Date.now(), planId,
2070
+ providerKey: `${typeof provider === 'string' && provider.length > 0 ? provider : 'deepseek'}:${String(modelId ?? 'default')}`,
2071
+ values: { ...buckets, calls: 1, cost, apiCost },
2128
2072
  }
2073
+ applyAccount(this, call)
2074
+ this.pendingAccounts.push(call)
2129
2075
  this.prune()
2130
2076
  this.scheduleWrite()
2131
2077
  }
@@ -2146,28 +2092,54 @@ export class Ledger {
2146
2092
  }, 2000)
2147
2093
  }
2148
2094
 
2149
- /** 立即落盘(原子写);写失败保留脏标记并按防抖重试(close 之后不再重排)。 */
2095
+ /** A detached, secret-free baseline for merging changes from other processes. */
2096
+ snapshot() {
2097
+ return structuredClone({
2098
+ version: LEDGER_VERSION, config: stripSecrets(this.config), days: this.days,
2099
+ balanceRef: this.balanceRef ?? null, migrations: this.migrations,
2100
+ planSamples: this.planSamples ?? {}, planHourBuckets: this.planHourBuckets ?? {},
2101
+ openrouterPriceHashes: this.openrouterPriceHashes ?? {}, historyReset: this.historyReset ?? null,
2102
+ })
2103
+ }
2104
+
2105
+ resetHistory() {
2106
+ this.days = {}
2107
+ this.planSamples = {}
2108
+ this.planHourBuckets = {}
2109
+ this.balanceRef = null
2110
+ this.pendingAccounts = []
2111
+ this.historyReset = { id: randomUUID(), at: Date.now() }
2112
+ this.scheduleWrite()
2113
+ }
2114
+
2115
+ /** Read, reconcile and atomically replace under one cross-process lock. */
2150
2116
  flush() {
2151
2117
  if (!this.pendingWrite || this.closed) return
2152
2118
  try {
2153
- mkdirSync(dirname(this.path), { recursive: true })
2154
- // 裁剪结果回写内存:只在持久化副本上裁剪会让内存桶无界增长。
2155
- this.planHourBuckets = pruneHourBuckets(this.planHourBuckets, Date.now())
2156
- const tmp = `${this.path}.tmp`
2157
- writeFileSync(tmp, JSON.stringify({
2158
- version: LEDGER_VERSION,
2159
- // 密钥脱敏(v1.6.8):内存中 this.config 仍保留明文供运行时解析(凭据库/环境变量
2160
- // 都取不到时的兜底),但**绝不写盘**——ledger.json 只存空占位字符串。
2161
- config: stripSecrets(this.config),
2162
- days: this.days,
2163
- balanceRef: this.balanceRef ?? null,
2164
- migrations: this.migrations,
2165
- planSamples: this.planSamples ?? {},
2166
- planHourBuckets: this.planHourBuckets,
2167
- openrouterPriceHashes: this.openrouterPriceHashes,
2168
- }), 'utf8')
2169
- renameSync(tmp, this.path)
2170
- this.pendingWrite = false
2119
+ withLedgerLock(this.path, () => {
2120
+ const raw = readLedger(this.path)
2121
+ if (raw !== null) {
2122
+ for (const migration of CONFIG_MIGRATIONS) {
2123
+ if (!(raw.migrations ?? []).includes(migration.id)) migration.apply(raw.config ?? {})
2124
+ }
2125
+ }
2126
+ const disk = raw === null ? null : {
2127
+ ...raw, config: stripSecrets(sanitizeConfig(raw.config)),
2128
+ days: sanitizeDays(raw.days ?? {}), balanceRef: raw.balanceRef ?? null,
2129
+ migrations: raw.migrations ?? [], planSamples: raw.planSamples ?? {},
2130
+ planHourBuckets: raw.planHourBuckets ?? convertRecentCallsToBuckets(raw.planRecentCalls ?? []),
2131
+ openrouterPriceHashes: raw.openrouterPriceHashes ?? {}, historyReset: raw.historyReset ?? null,
2132
+ }
2133
+ const merged = mergeLedger(this.writeBase, this.snapshot(), disk, this.pendingAccounts)
2134
+ // Keep runtime-only secret fallbacks in memory, never in the file or baseline.
2135
+ const config = sanitizeConfig(mergeRuntimeConfig(this.writeBase.config, this.config, merged.config))
2136
+ writeLedger(this.path, merged)
2137
+ this.config = config
2138
+ for (const key of ['days', 'balanceRef', 'migrations', 'planSamples', 'planHourBuckets', 'openrouterPriceHashes', 'historyReset']) this[key] = merged[key]
2139
+ this.writeBase = this.snapshot()
2140
+ this.pendingAccounts = []
2141
+ this.pendingWrite = false
2142
+ })
2171
2143
  } catch (error) {
2172
2144
  console.warn(`[dsh-cost-meter] 账本写入失败,稍后重试: ${String(error?.message ?? error)}`)
2173
2145
  if (!this.closed) this.scheduleWrite()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cost-meter",
3
- "version": "1.7.33",
3
+ "version": "1.7.34",
4
4
  "description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(11 家,含小米 MiMo Token Plan 控制台 Cookie 查询、SCNet Token Plan 本地 Credits 计量与火山方舟 Volcano Ark Coding Plan AK/SK 签名查询)、自定义 Provider 余额查询(可配任意 HTTP 端点)与余额进度条、峰谷计价时段显示与切换前弹窗/系统通知提醒(位置/提前量/类型可配),界面中英双语。Session cost tracking plugin for DeepSeek Harness: per-conversation cost, daily totals, history and official price sync, with multi-vendor model pricing (built-in 90+ model catalog with auto-matching), Coding Plan quota queries & display for 11 vendors (incl. Xiaomi MiMo Token Plan via console cookie, local credits metering for the SCNet Token Plan and Volcano Ark Coding Plan via AK/SK signature), plan/API dual-track billing with per-1% & full-window Token Plan usage estimates, custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, plus peak/off-peak pricing display with pre-switch popup & system-notification alerts (position / lead time / type configurable), in a bilingual (Chinese/English) UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",